From 2b326e778c4c76404b75602793f38173527ee1b9 Mon Sep 17 00:00:00 2001 From: jevansnyc Date: Wed, 15 Apr 2026 20:45:20 +0200 Subject: [PATCH 01/84] 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 00000000..454f3764 --- /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 8b55493b..ffad7892 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/84] 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 0fd0113f..06336a9b 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 5bcef694..4037bdf8 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/84] 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 40bcf7f2..796d633e 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/84] 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 4037bdf8..c614e5ed 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/84] 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 e06ac75e..65d1d777 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 e483ea62..a730efcd 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 52c869d7..74414220 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 469c1104..b21cb684 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 7bf3856c..f051c340 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/84] 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 7ed2da59..8ec94a9c 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 b44e215b..b79d2381 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 c2ecab33..8036b7ec 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/84] 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 0a699381..7e2783f2 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 1494d793..95b6d427 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 8ec94a9c..3a42ec2a 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/84] 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 7e2783f2..e908a201 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 95b6d427..ffb4a687 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/84] 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 b79d2381..0261110a 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 8036b7ec..da00c3ed 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/84] 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 79eca5a3..ba6c14bb 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 da00c3ed..43e090fe 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/84] 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 9ef6edb6..45e06660 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 73e489dc..193f702c 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/84] 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 45e06660..a3608d9e 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/84] 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 f051c340..a7fd99cb 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 62e112c7..46b87cc0 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 3c9be932..eca5e70f 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 0261110a..3cd27f2b 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/84] 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 a7fd99cb..fa3449fd 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/84] 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 fa3449fd..7a4a10df 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/84] 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 a3608d9e..86a8abe7 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/84] 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 193f702c..c7744ed2 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"); } From 5bb12d08257da12d3bfa43c85394ee4f4b6198e0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 7 May 2026 13:02:30 +0530 Subject: [PATCH 35/84] Add /__ts/page-bids endpoint for pushState/replaceState --- .../js/lib/src/integrations/gpt/index.test.ts | 16 +- crates/js/lib/src/integrations/gpt/index.ts | 159 ++++++++++++++---- .../trusted-server-adapter-fastly/src/main.rs | 14 +- crates/trusted-server-core/src/publisher.rs | 150 ++++++++++++++++- 4 files changed, 301 insertions(+), 38 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index e908a201..4d501ae3 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -13,6 +13,9 @@ type TestWindow = Window & { __ts_ad_slots?: unknown; __ts_bids?: unknown; __tsAdInit?: () => void; + __tsPrevGptSlots?: unknown; + __tsServicesEnabled?: boolean; + __tsSpaHookInstalled?: boolean; }; describe('installTsAdInit', () => { @@ -21,6 +24,9 @@ describe('installTsAdInit', () => { delete (window as TestWindow).__ts_ad_slots; delete (window as TestWindow).__ts_bids; delete (window as TestWindow).__tsAdInit; + delete (window as TestWindow).__tsPrevGptSlots; + delete (window as TestWindow).__tsSpaHookInstalled; + (window as TestWindow).__tsServicesEnabled = false; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { Object.defineProperty(navigator, 'sendBeacon', { @@ -203,7 +209,15 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = []; + (window as TestWindow).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: {}, + }, + ]; (window as TestWindow).__ts_bids = {}; const { installTsAdInit } = await import('./index'); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index ffb4a687..06bc7143 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -45,7 +45,7 @@ interface GoogleTagPubAdsService { getTargeting(key: string): string[]; enableSingleRequest(): void; addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; - refresh(): void; + refresh(slots?: GoogleTagSlot[]): void; } interface GoogleTag { @@ -56,6 +56,7 @@ interface GoogleTag { size: Array, elementId: string ): GoogleTagSlot | null; + destroySlots(slots?: GoogleTagSlot[]): boolean; enableServices(): void; display(elementId: string): void; _loaded_?: boolean; @@ -202,6 +203,8 @@ type TsWindow = Window & { __ts_ad_slots?: TsAdSlot[]; __ts_bids?: Record; __tsAdInit?: () => void; + __tsPrevGptSlots?: GoogleTagSlot[]; + __tsServicesEnabled?: boolean; }; /** @@ -212,6 +215,9 @@ type TsWindow = Window & { * targeting to GPT slots, sets the `ts_initial` sentinel, registers * `slotRenderEnded` to fire both nurl and burl via sendBeacon when our * specific Prebid bid wins the GAM line item match, then calls refresh(). + * + * Idempotent: destroys previously created TS-managed slots before redefining them, + * so it is safe to call again after SPA navigation updates `__ts_ad_slots`/`__ts_bids`. */ export function installTsAdInit(): void { const w = window as TsWindow; @@ -222,46 +228,128 @@ export function installTsAdInit(): void { if (!g) return; g.cmd?.push(() => { - slots - .map((slot) => { - 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)); - 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); - - g.pubads!().enableSingleRequest(); - g.enableServices?.(); - - g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { - 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); - } + // Destroy previously defined TS slots before redefining for the new page. + if (w.__tsPrevGptSlots && w.__tsPrevGptSlots.length > 0) { + g.destroySlots?.(w.__tsPrevGptSlots); + w.__tsPrevGptSlots = []; + } + + const newSlots: GoogleTagSlot[] = []; + + slots.forEach((slot) => { + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats as Array, + slot.div_id + ); + if (!gptSlot) return; + gptSlot.addService(g.pubads!()); + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); + const bid = bids[slot.id] ?? {}; + (['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!); + }); + gptSlot.setTargeting('ts_initial', '1'); + newSlots.push(gptSlot); }); - g.pubads!().refresh(); + w.__tsPrevGptSlots = newSlots; + + // enableSingleRequest and enableServices must only be called once per page load. + if (!w.__tsServicesEnabled) { + g.pubads!().enableSingleRequest(); + g.enableServices?.(); + w.__tsServicesEnabled = true; + + g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { + const slotId: string = event.slot?.getSlotElementId?.() ?? ''; + const bid = (w.__ts_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); + } + }); + } + + if (newSlots.length > 0) { + g.pubads!().refresh(newSlots); + } }); }; } +interface PageBidsResponse { + slots: TsAdSlot[]; + bids: Record; +} + +/** + * Install SPA navigation hook. + * + * Patches `history.pushState` and `history.replaceState`, and listens to + * `popstate`, so that after each client-side route change the trusted server + * fetches fresh slots + bids from `/__ts/page-bids?path=`, updates + * `window.__ts_ad_slots` / `window.__ts_bids`, and calls `window.__tsAdInit()`. + * + * Idempotent: guarded by `window.__tsSpaHookInstalled` so multiple calls are safe. + */ +export function installSpaAuctionHook(): void { + if (typeof window === 'undefined') return; + const win = window as TsWindow & { __tsSpaHookInstalled?: boolean }; + if (win.__tsSpaHookInstalled) return; + win.__tsSpaHookInstalled = true; + + let inflight: AbortController | null = null; + + async function onNavigate(path: string): Promise { + inflight?.abort(); + const controller = new AbortController(); + inflight = controller; + + try { + const res = await fetch(`/__ts/page-bids?path=${encodeURIComponent(path)}`, { + credentials: 'include', + signal: controller.signal, + }); + if (!res.ok) return; + const data = (await res.json()) as PageBidsResponse; + win.__ts_ad_slots = data.slots; + win.__ts_bids = data.bids; + win.__tsAdInit?.(); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + log.warn('SPA auction hook: fetch failed', err); + } + } + + function patchHistoryMethod(method: 'pushState' | 'replaceState'): void { + const original = history[method].bind(history); + history[method] = function ( + state: unknown, + unused: string, + url?: string | URL | null + ): void { + const prevPath = location.pathname; + original(state, unused, url); + const newPath = url ? new URL(String(url), location.href).pathname : location.pathname; + if (newPath !== prevPath) { + void onNavigate(newPath); + } + }; + } + + patchHistoryMethod('pushState'); + patchHistoryMethod('replaceState'); + + window.addEventListener('popstate', () => { + void onNavigate(location.pathname); + }); +} + /** * Register the slim-Prebid lazy loader. Fires after window.load — off the * critical path. slim-Prebid handles refresh auctions and userID module @@ -300,4 +388,5 @@ if (typeof window !== 'undefined') { } installTsAdInit(); + installSpaAuctionHook(); } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 74414220..55af1468 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -19,7 +19,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, PublisherResponse, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, + PublisherResponse, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -194,6 +195,17 @@ async fn route_request( } } + // SPA/CSR navigation endpoint — returns slots + bids JSON for the given path + (Method::GET, "/__ts/page-bids") => { + match runtime_services_for_consent_route(settings, runtime_services) { + Ok(publisher_services) => { + handle_page_bids(settings, orchestrator, &publisher_services, slots_file, req) + .await + } + Err(e) => Err(e), + } + } + // tsjs endpoints (Method::GET, "/first-party/proxy") => { handle_first_party_proxy(settings, runtime_services, req).await diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c7744ed2..ec4f4a22 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, no-store"); + response.set_header(header::CACHE_CONTROL, "private, max-age=0"); response.remove_header("surrogate-control"); response.remove_header("fastly-surrogate-control"); } @@ -990,6 +990,154 @@ fn apply_ec_headers( } } +/// Handle `GET /__ts/page-bids?path=` — server-side auction for SPA navigation. +/// +/// Matches creative opportunity slots for the given path, runs a server-side +/// auction (APS + PBS), and returns the slot definitions and winning bids as JSON. +/// Called by the client-side SPA navigation hook after `pushState` / `popstate`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] if cookie parsing or EC ID generation fails. +pub async fn handle_page_bids( + settings: &Settings, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + req: Request, +) -> Result> { + let Some(co_config) = &settings.creative_opportunities else { + return Ok(Response::from_status(StatusCode::NOT_FOUND) + .with_body_text_plain("Creative opportunities not configured")); + }; + + let path_param = req + .get_url() + .query_pairs() + .find(|(k, _)| k == "path") + .map(|(_, v)| v.into_owned()) + .unwrap_or_else(|| "/".to_string()); + + let matched_slots: Vec<_> = + crate::creative_opportunities::match_slots(&slots_file.slots, &path_param) + .into_iter() + .cloned() + .collect(); + + let request_info = crate::http_util::RequestInfo::from_request(&req, &services.client_info); + let cookie_jar = handle_request_cookies(&req)?; + let ec_id = get_or_generate_ec_id(settings, services, &req)?; + let geo = services + .geo() + .lookup(services.client_info.client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); + let consent_context = build_consent_context(&ConsentPipelineInput { + jar: cookie_jar.as_ref(), + req: &req, + config: &settings.consent, + geo: geo.as_ref(), + ec_id: Some(ec_id.as_str()), + kv_store: settings + .consent + .consent_store + .as_deref() + .map(|_| services.kv_store()), + }); + + let consent_allows_auction = consent_context + .tcf + .as_ref() + .is_some_and(|tcf| tcf.has_purpose_consent(1)); + + let winning_bids = if !matched_slots.is_empty() && consent_allows_auction { + let mut auction_request = build_auction_request( + &matched_slots, + &ec_id, + &consent_context, + &request_info, + co_config, + ); + let page_url = format!( + "{}://{}{}", + request_info.scheme, request_info.host, path_param + ); + auction_request.publisher.page_url = Some(page_url.clone()); + if let Some(ref mut site) = auction_request.site { + site.page = page_url; + } + let timeout_ms = co_config + .auction_timeout_ms + .unwrap_or(settings.auction.timeout_ms); + let placeholder_req = fastly::Request::get("https://placeholder.invalid/"); + let auction_context = AuctionContext { + settings, + request: &placeholder_req, + client_info: services.client_info(), + timeout_ms, + provider_responses: None, + services, + }; + match orchestrator + .run_auction(&auction_request, &auction_context, services) + .await + { + Ok(result) => result.winning_bids, + Err(e) => { + log::warn!("page-bids auction failed: {e:?}"); + std::collections::HashMap::new() + } + } + } else { + std::collections::HashMap::new() + }; + + let bid_map = build_bid_map(&winning_bids, co_config.price_granularity); + + let slots_json: 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| serde_json::json!([f.width, f.height])) + .collect(); + let targeting: serde_json::Map = slot + .targeting + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(); + serde_json::json!({ + "id": slot.id, + "gam_unit_path": gam_path, + "div_id": div_id, + "formats": formats, + "targeting": targeting, + }) + }) + .collect(); + + let body = serde_json::json!({ + "slots": slots_json, + "bids": bid_map, + }); + + let json_str = serde_json::to_string(&body).change_context(TrustedServerError::Proxy { + message: "Failed to serialize page-bids response".to_string(), + })?; + + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CONTENT_TYPE, "application/json"); + response.set_header(header::CACHE_CONTROL, "private, no-store"); + response.set_body(json_str); + + Ok(response) +} + #[cfg(test)] mod tests { use super::*; From 982fa3edbf8ed881797c6dff4aacd51d2878d68b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 7 May 2026 13:04:19 +0530 Subject: [PATCH 36/84] Fix format ts --- crates/js/lib/src/integrations/gpt/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 06bc7143..bf9fc99d 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -328,11 +328,7 @@ export function installSpaAuctionHook(): void { function patchHistoryMethod(method: 'pushState' | 'replaceState'): void { const original = history[method].bind(history); - history[method] = function ( - state: unknown, - unused: string, - url?: string | URL | null - ): void { + history[method] = function (state: unknown, unused: string, url?: string | URL | null): void { const prevPath = location.pathname; original(state, unused, url); const newPath = url ? new URL(String(url), location.href).pathname : location.pathname; From 77d3c4a2e92f7d098c901322637463657bdb01ee Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 7 May 2026 15:46:49 +0530 Subject: [PATCH 37/84] =?UTF-8?q?=5F=5FtsDivToSlotId=20now=20replaced=20pe?= =?UTF-8?q?r=20navigation=20(not=20merged)=20=E2=80=94=20stale=20div=5Fid?= =?UTF-8?q?=20entries=20from=20destroyed=20slots=20no=20longer=20persist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/lib/src/integrations/gpt/index.test.ts | 138 ++++++++++++++++-- crates/js/lib/src/integrations/gpt/index.ts | 17 ++- 2 files changed, 138 insertions(+), 17 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 4d501ae3..87455591 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -16,6 +16,7 @@ type TestWindow = Window & { __tsPrevGptSlots?: unknown; __tsServicesEnabled?: boolean; __tsSpaHookInstalled?: boolean; + __tsDivToSlotId?: Record; }; describe('installTsAdInit', () => { @@ -26,6 +27,7 @@ describe('installTsAdInit', () => { delete (window as TestWindow).__tsAdInit; delete (window as TestWindow).__tsPrevGptSlots; delete (window as TestWindow).__tsSpaHookInstalled; + delete (window as TestWindow).__tsDivToSlotId; (window as TestWindow).__tsServicesEnabled = false; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { @@ -41,7 +43,7 @@ describe('installTsAdInit', () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('atf'), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue(['abc']), }; const mockPubads = { @@ -57,15 +59,15 @@ describe('installTsAdInit', () => { }; (window as TestWindow).__ts_ad_slots = [ { - id: 'atf', + id: 'atf_sidebar_ad', gam_unit_path: '/123/atf', - div_id: 'atf', + div_id: 'div-atf-sidebar', formats: [[300, 250]], targeting: { pos: 'atf' }, }, ]; (window as TestWindow).__ts_bids = { - atf: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc', @@ -96,7 +98,7 @@ describe('installTsAdInit', () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('atf'), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue(['abc']), }; const mockPubads = { @@ -114,15 +116,15 @@ describe('installTsAdInit', () => { }; (window as TestWindow).__ts_ad_slots = [ { - id: 'atf', + id: 'atf_sidebar_ad', gam_unit_path: '/123/atf', - div_id: 'atf', + div_id: 'div-atf-sidebar', formats: [[300, 250]], targeting: {}, }, ]; (window as TestWindow).__ts_bids = { - atf: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc', @@ -143,6 +145,64 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); + it('fires beacons for APS bid (no hb_adid) when ad renders in our slot', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (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 TestWindow).__ts_ad_slots = [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + (window as TestWindow).__ts_bids = { + atf_sidebar_ad: { + hb_pb: '1.50', + hb_bidder: 'aps', + nurl: 'https://aps/win', + burl: 'https://aps/bill', + }, + }; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).__tsAdInit!(); + + expect(capturedListener).toBeDefined(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + + expect(beaconSpy).toHaveBeenCalledWith('https://aps/win'); + expect(beaconSpy).toHaveBeenCalledWith('https://aps/bill'); + + beaconSpy.mockClear(); + capturedListener!({ isEmpty: true, slot: mockSlot }); + expect(beaconSpy).not.toHaveBeenCalled(); + + 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: SlotRenderEvent) => void) | undefined; @@ -150,7 +210,7 @@ describe('installTsAdInit', () => { const mockSlotNoMatch = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('atf'), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), }; const mockPubads = { @@ -168,15 +228,15 @@ describe('installTsAdInit', () => { }; (window as TestWindow).__ts_ad_slots = [ { - id: 'atf', + id: 'atf_sidebar_ad', gam_unit_path: '/123/atf', - div_id: 'atf', + div_id: 'div-atf-sidebar', formats: [[300, 250]], targeting: {}, }, ]; (window as TestWindow).__ts_bids = { - atf: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc', @@ -194,6 +254,56 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); + it('does not fire beacons for slotRenderEnded on slots not owned by TS', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue(['abc']), + }; + const arenaSlot = { + getSlotElementId: () => 'arena-owned-div', + getTargeting: () => [], + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (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 TestWindow).__ts_ad_slots = [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + (window as TestWindow).__ts_bids = { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, + }; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).__tsAdInit!(); + + capturedListener!({ isEmpty: false, slot: arenaSlot }); + + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + it('calls refresh even when __ts_bids is empty (graceful fallback)', async () => { const mockPubads = { enableSingleRequest: vi.fn(), @@ -211,9 +321,9 @@ describe('installTsAdInit', () => { }; (window as TestWindow).__ts_ad_slots = [ { - id: 'atf', + id: 'atf_sidebar_ad', gam_unit_path: '/123/atf', - div_id: 'atf', + div_id: 'div-atf-sidebar', formats: [[300, 250]], targeting: {}, }, diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index bf9fc99d..fee79c1b 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -205,6 +205,7 @@ type TsWindow = Window & { __tsAdInit?: () => void; __tsPrevGptSlots?: GoogleTagSlot[]; __tsServicesEnabled?: boolean; + __tsDivToSlotId?: Record; }; /** @@ -235,6 +236,7 @@ export function installTsAdInit(): void { } const newSlots: GoogleTagSlot[] = []; + const divToSlotId: Record = {}; slots.forEach((slot) => { const gptSlot = g.defineSlot?.( @@ -250,10 +252,13 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, bid[key]!); }); gptSlot.setTargeting('ts_initial', '1'); + divToSlotId[slot.div_id] = slot.id; newSlots.push(gptSlot); }); w.__tsPrevGptSlots = newSlots; + // Replace (not merge) so destroyed slots from previous navigation don't linger. + w.__tsDivToSlotId = divToSlotId; // enableSingleRequest and enableServices must only be called once per page load. if (!w.__tsServicesEnabled) { @@ -262,12 +267,18 @@ export function installTsAdInit(): void { w.__tsServicesEnabled = true; g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { - const slotId: string = event.slot?.getSlotElementId?.() ?? ''; + const divId: string = event.slot?.getSlotElementId?.() ?? ''; + const slotId = (w.__tsDivToSlotId ?? {})[divId]; + if (!slotId) return; const bid = (w.__ts_bids ?? {})[slotId] ?? {}; + // Prebid: compare hb_adid targeting to verify the specific creative won. + // APS: no hb_adid equivalent — fires if bidder exists and slot is non-empty. + // Known limitation: APS path may over-fire if a non-APS line item wins. const ourBidWon = !event.isEmpty && - bid.hb_adid && - event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid; + (bid.hb_adid + ? event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + : !!bid.hb_bidder); if (ourBidWon) { if (bid.nurl) navigator.sendBeacon(bid.nurl); if (bid.burl) navigator.sendBeacon(bid.burl); From 38c8bf17701dea1a76ba1344620f726a0a18711b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 7 May 2026 17:20:49 +0530 Subject: [PATCH 38/84] Update timeout for mocktioneer --- trusted-server.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/trusted-server.toml b/trusted-server.toml index 43e090fe..d17e8647 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -172,7 +172,7 @@ allowed_context_keys = ["permutive_segments"] enabled = true pub_id = "test-pub" endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" -timeout_ms = 1000 +timeout_ms = 400 [integrations.google_tag_manager] enabled = false @@ -182,7 +182,7 @@ container_id = "GTM-XXXXXX" [integrations.adserver_mock] enabled = true endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" -timeout_ms = 1000 +timeout_ms = 400 # Map auction-request context keys to mediation URL query parameters. # Each key is a context key from the JS client; the value becomes the @@ -192,6 +192,6 @@ permutive_segments = "permutive" [creative_opportunities] gam_network_id = "88059007" -auction_timeout_ms = 1500 +auction_timeout_ms = 500 price_granularity = "dense" From e32bfa556e99d1fb225876c286e2fb7164317c9f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 7 May 2026 17:28:59 +0530 Subject: [PATCH 39/84] Revert with updated tiomeout --- trusted-server.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/trusted-server.toml b/trusted-server.toml index d17e8647..43e090fe 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -172,7 +172,7 @@ allowed_context_keys = ["permutive_segments"] enabled = true pub_id = "test-pub" endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" -timeout_ms = 400 +timeout_ms = 1000 [integrations.google_tag_manager] enabled = false @@ -182,7 +182,7 @@ container_id = "GTM-XXXXXX" [integrations.adserver_mock] enabled = true endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" -timeout_ms = 400 +timeout_ms = 1000 # Map auction-request context keys to mediation URL query parameters. # Each key is a context key from the JS client; the value becomes the @@ -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 b1e74c986ec44f78053d4ef2dbadfc34697bb824 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 9 May 2026 14:45:34 +0530 Subject: [PATCH 40/84] Wip: Align with the spec --- .../trusted-server-adapter-fastly/src/main.rs | 18 +- .../src/auction/orchestrator.rs | 338 ++++++++++++++++++ .../src/creative_opportunities.rs | 16 +- .../trusted-server-core/src/html_processor.rs | 47 ++- crates/trusted-server-core/src/publisher.rs | 329 +++++++++++++++-- trusted-server.toml | 6 + 6 files changed, 710 insertions(+), 44 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 55af1468..895299f5 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -19,7 +19,7 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body_async, PublisherResponse, }; use trusted_server_core::request_signing::{ @@ -250,18 +250,26 @@ async fn route_request( Ok(PublisherResponse::Stream { mut response, body, - params, + mut params, }) => { // Streaming path: finalize headers, then stream body to client. + // TTFB happens at stream_to_client() — SSP bids are already + // in-flight in Fastly's native layer (dispatched before origin wait). finalize_response(settings, geo_info.as_ref(), &mut response); let mut streaming_body = response.stream_to_client(); - if let Err(e) = stream_publisher_body( + // stream_publisher_body_async falls back to the sync path + // when no auction was dispatched (dispatched_auction is None). + let stream_result = stream_publisher_body_async( body, &mut streaming_body, - ¶ms, + &mut *params, settings, integration_registry, - ) { + orchestrator, + &publisher_services, + ) + .await; + if let Err(e) = stream_result { // Headers already committed. Log and abort — client // sees a truncated response. Standard proxy behavior. log::error!("Streaming processing failed: {e:?}"); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 0a52b07c..953ed6a0 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -13,6 +13,23 @@ use super::config::AuctionConfig; use super::provider::AuctionProvider; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +/// In-flight auction requests dispatched to SSP backends. +/// +/// Created by [`AuctionOrchestrator::dispatch_auction`] and consumed by +/// [`AuctionOrchestrator::collect_dispatched_auction`]. Carrying this handle +/// across `pending_origin.wait()` lets origin response and SSP HTTP requests +/// race in Fastly's native layer, enabling TTFB ≈ origin latency rather than +/// TTFB ≈ auction timeout. +pub struct DispatchedAuction { + pending_requests: Vec, + backend_to_provider: HashMap)>, + auction_start: Instant, + timeout_ms: u32, + floor_prices: HashMap, + /// Carried so the mediator call in collect can pass it as the auction request. + request: AuctionRequest, +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -584,6 +601,327 @@ impl AuctionOrchestrator { }) } + /// Dispatch SSP bid requests without blocking WASM. + /// + /// Calls each enabled provider's [`AuctionProvider::request_bids`] (which + /// internally calls Fastly's `send_async`), then returns immediately with a + /// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips + /// while WASM continues to `pending_origin.wait()`. + /// + /// Returns `None` when no providers are configured or all providers are + /// disabled / over budget. The caller should fall back to the synchronous + /// `run_auction` path. + #[must_use] + pub fn dispatch_auction( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Option { + let provider_names = self.config.provider_names(); + if provider_names.is_empty() { + return None; + } + + let auction_start = Instant::now(); + let mut backend_to_provider: HashMap)> = + HashMap::new(); + let mut pending_requests: Vec = Vec::new(); + + for provider_name in provider_names { + let provider = match self.providers.get(provider_name) { + Some(p) => p, + None => { + log::warn!("Provider '{}' not registered, skipping", provider_name); + continue; + } + }; + + if !provider.is_enabled() { + log::debug!("Provider '{}' is disabled, skipping", provider.provider_name()); + continue; + } + + let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); + let effective_timeout = remaining_ms.min(provider.timeout_ms()); + + if effective_timeout == 0 { + log::warn!( + "Auction timeout ({}ms) exhausted before launching '{}' — skipping", + context.timeout_ms, + provider.provider_name() + ); + continue; + } + + let backend_name = match provider.backend_name(effective_timeout) { + Some(name) => name, + None => { + log::warn!("Provider '{}' has no backend_name, skipping", provider.provider_name()); + continue; + } + }; + + let provider_context = AuctionContext { + settings: context.settings, + request: context.request, + client_info: context.client_info, + timeout_ms: effective_timeout, + provider_responses: context.provider_responses, + services: context.services, + }; + + let start_time = Instant::now(); + match provider.request_bids(request, &provider_context) { + Ok(pending) => { + log::info!( + "Dispatching bid request to '{}' (backend: {}, budget: {}ms)", + provider.provider_name(), + backend_name, + effective_timeout + ); + backend_to_provider.insert( + backend_name.clone(), + (provider.provider_name().to_string(), start_time, Arc::clone(provider)), + ); + pending_requests + .push(PlatformPendingRequest::new(pending).with_backend_name(backend_name)); + } + Err(e) => { + log::warn!( + "Provider '{}' failed to dispatch request: {:?}", + provider.provider_name(), + e + ); + } + } + } + + if pending_requests.is_empty() { + return None; + } + + log::info!( + "Dispatched {} SSP requests (timeout: {}ms); Fastly host will race them against origin", + pending_requests.len(), + context.timeout_ms + ); + + Some(DispatchedAuction { + pending_requests, + backend_to_provider, + auction_start, + timeout_ms: context.timeout_ms, + floor_prices: self.floor_prices_by_slot(request), + request: request.clone(), + }) + } + + /// Collect bid responses from a previously-dispatched auction. + /// + /// Runs the select-loop phase (equivalent to Phase 2 of + /// `run_providers_parallel`) and, if the orchestrator has a mediator + /// configured, forwards collected bids to it. The overall auction deadline + /// is enforced from `dispatched.auction_start`. + /// + /// On any error or partial failure the method returns the best available + /// result rather than propagating — the caller should still inject the + /// winning bids even if some providers timed out. + pub async fn collect_dispatched_auction( + &self, + dispatched: DispatchedAuction, + services: &RuntimeServices, + context: &AuctionContext<'_>, + ) -> OrchestrationResult { + let DispatchedAuction { + pending_requests, + mut backend_to_provider, + auction_start, + timeout_ms, + floor_prices, + request, + } = dispatched; + + let deadline = Duration::from_millis(u64::from(timeout_ms)); + + log::info!( + "Collecting {} in-flight SSP responses (timeout: {}ms remaining: {}ms)", + pending_requests.len(), + timeout_ms, + remaining_budget_ms(auction_start, timeout_ms), + ); + + let mut responses: Vec = Vec::new(); + let mut remaining = pending_requests; + + while !remaining.is_empty() { + let select_result = match services + .http_client() + .select(remaining) + .await + .change_context(TrustedServerError::Auction { + message: "HTTP select failed".to_string(), + }) { + Ok(r) => r, + Err(e) => { + log::warn!("select() failed during auction collection: {:?}", e); + break; + } + }; + remaining = select_result.remaining; + + match select_result.ready { + Ok(platform_response) => { + let backend_name = platform_response.backend_name.clone().unwrap_or_default(); + if let Some((provider_name, start_time, provider)) = + backend_to_provider.remove(&backend_name) + { + let response_time_ms = start_time.elapsed().as_millis() as u64; + match platform_response_to_fastly(platform_response) { + Ok(response) => match provider.parse_response(response, response_time_ms) { + Ok(auction_response) => { + log::info!( + "Provider '{}' returned {} bids ({}ms)", + auction_response.provider, + auction_response.bids.len(), + auction_response.response_time_ms + ); + responses.push(auction_response); + } + Err(e) => { + log::warn!("Provider '{}' parse failed: {:?}", provider_name, e); + responses.push(AuctionResponse::error(&provider_name, response_time_ms)); + } + }, + Err(e) => { + log::warn!("Provider '{}' unsupported body: {:?}", provider_name, e); + responses.push(AuctionResponse::error(&provider_name, response_time_ms)); + } + } + } else { + log::warn!("Received response from unknown backend '{}', ignoring", backend_name); + } + } + Err(e) => { + log::warn!("A provider request failed during collection: {:?}", e); + } + } + + if auction_start.elapsed() >= deadline && !remaining.is_empty() { + log::warn!( + "Auction timeout ({}ms) reached, dropping {} remaining request(s)", + timeout_ms, + remaining.len() + ); + break; + } + } + + let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { + match self.providers.get(mediator_name.as_str()) { + Some(mediator) => { + let remaining_ms = remaining_budget_ms(auction_start, timeout_ms); + if remaining_ms == 0 { + log::warn!("Auction timeout exhausted during bidding — skipping mediator"); + let winning = self.select_winning_bids(&responses, &floor_prices); + return OrchestrationResult { + provider_responses: responses, + mediator_response: None, + winning_bids: winning, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: HashMap::new(), + }; + } + let placeholder = fastly::Request::get("https://placeholder.invalid/"); + let mediator_context = AuctionContext { + settings: context.settings, + request: &placeholder, + client_info: context.client_info, + timeout_ms: remaining_ms, + provider_responses: Some(&responses), + services: context.services, + }; + match mediator.request_bids(&request, &mediator_context) { + Ok(pending) => { + let platform_resp = services + .http_client() + .wait(PlatformPendingRequest::new(pending)) + .await; + match platform_resp.change_context(TrustedServerError::Auction { + message: format!("Mediator {} request failed", mediator.provider_name()), + }) { + Ok(platform_resp) => { + match platform_response_to_fastly(platform_resp).change_context( + TrustedServerError::Auction { + message: format!("Mediator {} unsupported body", mediator.provider_name()), + }, + ) { + Ok(response) => { + let response_time_ms = + remaining_ms as u64 - remaining_budget_ms(auction_start, timeout_ms) as u64; + match mediator.parse_response(response, response_time_ms) { + Ok(mediator_resp) => { + let winning = mediator_resp + .bids + .iter() + .filter_map(|bid| { + if bid.price.is_none() { + log::warn!( + "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", + mediator.provider_name(), + bid.slot_id + ); + None + } else { + Some((bid.slot_id.clone(), bid.clone())) + } + }) + .collect(); + let winning = self.apply_floor_prices(winning, &floor_prices); + (Some(mediator_resp), winning) + } + Err(e) => { + log::warn!("Mediator '{}' parse failed: {:?}", mediator.provider_name(), e); + let winning = self.select_winning_bids(&responses, &floor_prices); + (None, winning) + } + } + } + Err(e) => { + log::warn!("Mediator body error: {:?}", e); + (None, self.select_winning_bids(&responses, &floor_prices)) + } + } + } + Err(e) => { + log::warn!("Mediator request failed: {:?}", e); + (None, self.select_winning_bids(&responses, &floor_prices)) + } + } + } + Err(e) => { + log::warn!("Mediator '{}' failed to dispatch: {:?}", mediator.provider_name(), e); + (None, self.select_winning_bids(&responses, &floor_prices)) + } + } + } + None => { + log::warn!("Mediator '{}' not registered", mediator_name); + (None, self.select_winning_bids(&responses, &floor_prices)) + } + } + } else { + (None, self.select_winning_bids(&responses, &floor_prices)) + }; + + OrchestrationResult { + provider_responses: responses, + mediator_response, + winning_bids, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: HashMap::new(), + } + } + /// Check if orchestrator is enabled. #[must_use] pub fn is_enabled(&self) -> bool { diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 7a4a10df..12957d4b 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -18,7 +18,21 @@ use crate::price_bucket::PriceGranularity; pub struct CreativeOpportunitiesConfig { /// GAM network ID used to build default unit paths. pub gam_network_id: String, - /// Auction timeout in milliseconds. + /// Maximum time in milliseconds to wait for the server-side auction before + /// closing the response body. + /// + /// The auction runs concurrently with HTML body streaming. Body content + /// above `` has already been delivered and painted before the hold + /// begins, so **FCP is not affected**. What this timeout bounds is the slip + /// on `DOMContentLoaded` and `window.load`: third-party scripts that hook + /// those events fire later by at most this duration. + /// + /// The worst case is a cache-hit page where the origin drains in <50 ms + /// but the auction takes the full timeout — the browser sits idle waiting + /// for ``. 500 ms is the recommended default and the hard upper + /// bound on DCL slip the publisher is willing to accept. + /// + /// When absent, falls back to `[auction].timeout_ms` from global config. #[serde(default)] pub auction_timeout_ms: Option, /// Price granularity for header-bidding price bucketing. diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 86a8abe7..26978cef 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -292,13 +292,21 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) } }), - // Inject __ts_bids before via end_tag_handlers. + // Inject __ts_bids before via end_tag_handlers — only when + // slots matched this URL. When no slots matched, skip injection entirely + // so the publisher's existing client-side Prebid/GPT flow is unmodified + // (dual-mode rollout: calling __tsAdInit with empty slots would invoke + // enableSingleRequest/enableServices and conflict with the publisher's GPT init). // Guard with AtomicBool so the script is only injected once even if // the origin HTML contains multiple elements (e.g. template fragments). element!("body", { let state = ad_bids_state.clone(); let injected_bids = injected_bids.clone(); + let has_slots = ad_slots_script.is_some(); move |el| { + if !has_slots { + return Ok(()); + } let state = state.clone(); let injected_bids = injected_bids.clone(); if let Some(handlers) = el.end_tag_handlers() { @@ -1285,7 +1293,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: None, + ad_slots_script: Some("".to_string()), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1314,7 +1322,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: None, + ad_slots_script: Some("".to_string()), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1331,14 +1339,16 @@ mod tests { } #[test] - fn injects_empty_ts_bids_when_state_is_none() { + fn injects_empty_ts_bids_when_slots_matched_but_auction_returned_nothing() { + // Slots matched (ad_slots_script is Some) but auction task never wrote a result + // (state is None) — e.g. auction timed out with zero bids. Fallback to {}. 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_slots_script: Some("".to_string()), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1348,7 +1358,32 @@ mod tests { 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" + "should inject empty bids fallback when auction produced nothing" + ); + } + + #[test] + fn does_not_inject_ts_bids_when_no_slots_matched() { + // No slots matched this URL — ad_slots_script is None. __ts_bids must be + // omitted entirely so the publisher's existing client-side GPT flow is + // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). + let state = std::sync::Arc::new(std::sync::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"), + "should NOT inject __ts_bids when no slots matched" ); } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ec4f4a22..4a39c962 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -18,7 +18,7 @@ use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; -use crate::auction::orchestrator::AuctionOrchestrator; +use crate::auction::orchestrator::{AuctionOrchestrator, DispatchedAuction}; use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, PublisherInfo, SiteInfo, UserInfo, }; @@ -31,7 +31,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::price_bucket::{price_bucket, PriceGranularity}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -301,8 +301,9 @@ pub enum PublisherResponse { response: Response, /// Origin body to be piped through the streaming pipeline. body: Body, - /// Parameters for `process_response_streaming`. - params: OwnedProcessResponseParams, + /// Parameters for `process_response_streaming`. Boxed to keep this + /// variant's on-stack size comparable to the other variants. + params: Box, }, /// Non-processable 2xx response (images, fonts, video). The adapter must /// reattach the body via `response.set_body(body)` before returning. @@ -407,6 +408,12 @@ pub struct OwnedProcessResponseParams { pub(crate) content_type: String, pub(crate) ad_slots_script: Option, pub(crate) ad_bids_state: Arc>>, + /// In-flight SSP bids dispatched before `pending_origin.wait()`. + /// The streaming phase collects these and writes bids to `ad_bids_state` + /// before processing the last body chunk, so `` injection sees live bids. + pub(crate) dispatched_auction: Option, + /// Price granularity used to bucket bids when building `__ts_bids`. + pub(crate) price_granularity: PriceGranularity, } /// Stream the publisher response body through the processing pipeline. @@ -441,6 +448,261 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed) } +/// Stream publisher body with a "last-chunk hold" for live bid injection. +/// +/// Drives the origin body through the HTML pipeline one chunk at a time, using a +/// one-behind buffer so the last raw origin chunk is held back. When the origin +/// body is exhausted (`read` returns `Ok(0)`): +/// +/// 1. [`collect_dispatched_auction`](AuctionOrchestrator::collect_dispatched_auction) +/// is awaited with the remaining deadline. +/// 2. Winning bids are written to `ad_bids_state`. +/// 3. The held last chunk is fed through the pipeline — `lol_html` fires its +/// `` handler with bids now in state. +/// +/// For non-HTML content types the auction is collected before any body bytes +/// are written (no `` to inject). If `params.dispatched_auction` is +/// `None` the function falls back to the synchronous +/// [`stream_publisher_body`] path. +/// +/// # Errors +/// +/// Returns an error if processing fails mid-stream. Headers are already +/// committed at that point; the caller logs and drops the `StreamingBody`. +pub async fn stream_publisher_body_async( + body: Body, + output: &mut W, + params: &mut OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, +) -> Result<(), Report> { + let Some(dispatched) = params.dispatched_auction.take() else { + // No auction — use the existing sync pipeline unchanged. + return stream_publisher_body(body, output, params, settings, integration_registry); + }; + + let is_html = params.content_type.contains("text/html"); + + if !is_html { + // Non-HTML: collect auction first, then stream. There is no + // to hold, so delaying the entire body until collection is acceptable. + let placeholder = Request::get("https://placeholder.invalid/"); + let result = orchestrator + .collect_dispatched_auction(dispatched, services, &make_collect_context(settings, services, &placeholder)) + .await; + write_bids_to_state(&result.winning_bids, params.price_granularity, ¶ms.ad_bids_state); + return stream_publisher_body(body, output, params, settings, integration_registry); + } + + // HTML: build the processor once and drive it chunk by chunk. + // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin + // EOF, then await auction and process chunk N (which contains ). + let mut processor = create_html_stream_processor( + ¶ms.origin_host, + ¶ms.request_host, + ¶ms.request_scheme, + settings, + integration_registry, + params.ad_slots_script.as_deref().map(str::to_string), + params.ad_bids_state.clone(), + )?; + + let compression = Compression::from_content_encoding(¶ms.content_encoding); + stream_html_with_auction_hold( + body, + output, + &mut processor, + compression, + AuctionCollectCtx { + dispatched, + price_granularity: params.price_granularity, + ad_bids_state: ¶ms.ad_bids_state, + orchestrator, + services, + settings, + }, + ) + .await +} + +/// Build a minimal [`AuctionContext`] for the mediator call in collection. +/// +/// The `request` field is a short-lived placeholder (providers use it only for +/// header extraction; the placeholder is functionally equivalent to the original +/// since `req` was already consumed by `send_async` before dispatch). +fn make_collect_context<'a>( + settings: &'a Settings, + services: &'a RuntimeServices, + placeholder: &'a Request, +) -> AuctionContext<'a> { + AuctionContext { + settings, + request: placeholder, + client_info: services.client_info(), + timeout_ms: 0, + provider_responses: None, + services, + } +} + +/// Write winning bids from an auction result into the shared `ad_bids_state` lock. +pub(crate) fn write_bids_to_state( + winning_bids: &std::collections::HashMap, + price_granularity: PriceGranularity, + ad_bids_state: &Arc>>, +) { + let bid_map = build_bid_map(winning_bids, price_granularity); + let bids_script = build_bids_script(&bid_map); + *ad_bids_state.write().expect("should write bid state") = Some(bids_script); +} + +/// Bundles the auction-collection dependencies passed through the streaming helpers. +struct AuctionCollectCtx<'a> { + dispatched: DispatchedAuction, + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, +} + +/// Run the one-behind chunk loop for HTML bodies, collecting the auction before +/// the last chunk so `lol_html`'s `` handler sees live bids. +async fn stream_html_with_auction_hold( + body: Body, + output: &mut W, + processor: &mut P, + compression: Compression, + ctx: AuctionCollectCtx<'_>, +) -> Result<(), Report> { + use brotli::enc::writer::CompressorWriter; + use brotli::enc::BrotliEncoderParams; + use brotli::Decompressor; + use flate2::read::{GzDecoder, ZlibDecoder}; + use flate2::write::{GzEncoder, ZlibEncoder}; + + match compression { + Compression::None => one_behind_loop(body, output, processor, ctx).await, + Compression::Gzip => { + let decoder = GzDecoder::new(body); + let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); + one_behind_loop(decoder, &mut encoder, processor, ctx).await?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip encoder".to_string(), + })?; + Ok(()) + } + Compression::Deflate => { + let decoder = ZlibDecoder::new(body); + let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); + one_behind_loop(decoder, &mut encoder, processor, ctx).await?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate encoder".to_string(), + })?; + Ok(()) + } + Compression::Brotli => { + let decoder = Decompressor::new(body, 4096); + let params = BrotliEncoderParams { + quality: 4, + lgwin: 22, + ..Default::default() + }; + let mut encoder = CompressorWriter::with_params(&mut *output, 4096, ¶ms); + one_behind_loop(decoder, &mut encoder, processor, ctx).await?; + let _ = encoder.into_inner(); + Ok(()) + } + } +} + +/// Core one-behind chunk loop. +/// +/// Reads from `reader`, writing processed output to `writer` for every chunk +/// except the current one (which is held pending). On EOF, the auction is +/// collected, bids written, and the held chunk processed last. +async fn one_behind_loop( + mut reader: R, + writer: &mut W, + processor: &mut P, + ctx: AuctionCollectCtx<'_>, +) -> Result<(), Report> { + let AuctionCollectCtx { dispatched, price_granularity, ad_bids_state, orchestrator, services, settings } = ctx; + const CHUNK_SIZE: usize = 8192; + let mut buffer = vec![0u8; CHUNK_SIZE]; + let mut pending: Vec = Vec::new(); + + loop { + match reader.read(&mut buffer) { + Ok(0) => { + // Origin exhausted — pending holds the last chunk. + // Collect the auction before feeding it to lol_html so that + // the handler sees populated ad_bids_state. + let placeholder = Request::get("https://placeholder.invalid/"); + let collect_ctx = make_collect_context(settings, services, &placeholder); + let result = orchestrator + .collect_dispatched_auction(dispatched, services, &collect_ctx) + .await; + write_bids_to_state(&result.winning_bids, price_granularity, ad_bids_state); + + // Process the held last chunk (not is_last — finalization is separate). + if !pending.is_empty() { + let out = processor.process_chunk(&pending, false).change_context( + TrustedServerError::Proxy { + message: "Failed to process last chunk".to_string(), + }, + )?; + if !out.is_empty() { + writer.write_all(&out).change_context(TrustedServerError::Proxy { + message: "Failed to write last chunk".to_string(), + })?; + } + } + // Signal EOF to lol_html (fires end() which flushes remaining state). + let final_out = processor.process_chunk(&[], true).change_context( + TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + }, + )?; + if !final_out.is_empty() { + writer.write_all(&final_out).change_context(TrustedServerError::Proxy { + message: "Failed to write finalized output".to_string(), + })?; + } + break; + } + Ok(n) => { + // Stream the previously held chunk (it is not the last). + if !pending.is_empty() { + let out = processor.process_chunk(&pending, false).change_context( + TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }, + )?; + if !out.is_empty() { + writer.write_all(&out).change_context(TrustedServerError::Proxy { + message: "Failed to write chunk".to_string(), + })?; + } + } + pending = buffer[..n].to_vec(); + } + Err(e) => { + return Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to read origin body: {e}"), + })); + } + } + } + + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output".to_string(), + })?; + Ok(()) +} + /// Proxies requests to the publisher's origin server. /// /// Returns a [`PublisherResponse`] indicating how the response should be sent: @@ -590,13 +852,22 @@ pub async fn handle_publisher_request( restrict_accept_encoding(&mut req); req.set_header("host", &origin_host); + // Dispatch origin request first. 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 { + // Dispatch SSP bid requests BEFORE awaiting origin — all HTTP is now in-flight + // in Fastly's native layer. WASM yields only for origin (fast, cache-hit path), + // so TTFB ≈ origin latency instead of TTFB ≈ auction timeout. + let price_granularity = settings + .creative_opportunities + .as_ref() + .map(|co| co.price_granularity) + .unwrap_or_default(); + let dispatched_auction = if should_run_auction { let co_config = settings .creative_opportunities .as_ref() @@ -617,35 +888,12 @@ pub async fn handle_publisher_request( 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 - } - } + orchestrator.dispatch_auction(&auction_request, &auction_context) } 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); - } - + // Now yield for origin — SSP requests are already racing in Fastly's native layer. let mut response = pending_origin .wait() .change_context(TrustedServerError::Proxy { @@ -754,7 +1002,7 @@ pub async fn handle_publisher_request( Ok(PublisherResponse::Stream { response, body, - params: OwnedProcessResponseParams { + params: Box::new(OwnedProcessResponseParams { content_encoding, origin_host, origin_url: settings.publisher.origin_url.clone(), @@ -763,7 +1011,9 @@ pub async fn handle_publisher_request( content_type, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), - }, + dispatched_auction, + price_granularity, + }), }) } ResponseRoute::BufferedProcessed => { @@ -1790,6 +2040,9 @@ mod tests { content_type: "text/css".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(RwLock::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; let mut output = Vec::new(); @@ -1833,6 +2086,9 @@ mod tests { content_type: "text/html; charset=utf-8".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(RwLock::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; let mut output = Vec::new(); @@ -1867,6 +2123,9 @@ mod tests { content_type: "text/html".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(RwLock::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; let bogus_body = Body::from(b"not gzip".to_vec()); @@ -1968,6 +2227,9 @@ mod tests { content_type: "text/html; charset=utf-8".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(RwLock::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -2020,6 +2282,9 @@ mod tests { content_type: "text/html".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(RwLock::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; let mut output = Vec::new(); diff --git a/trusted-server.toml b/trusted-server.toml index 43e090fe..b1b5a0b0 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -192,6 +192,12 @@ permutive_segments = "permutive" [creative_opportunities] gam_network_id = "88059007" +# FCP is not affected by this value — body content above has already +# streamed and painted before the hold begins. What this caps is the slip on +# DOMContentLoaded and window.load. Worst case: a cache-hit page where origin +# drains in <50 ms but the auction runs to the limit. 500 ms is the recommended +# default; raise only if your SSPs need more headroom and your analytics confirm +# the DCL slip is acceptable. auction_timeout_ms = 1500 price_granularity = "dense" From a2d08e78ea7c4bc3fd73c4259d3cb4b66f37a153 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sun, 10 May 2026 16:13:47 +0530 Subject: [PATCH 41/84] Fix clippy explicit-auto-deref in stream_publisher_body_async call --- crates/trusted-server-adapter-fastly/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 895299f5..94f09519 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -262,7 +262,7 @@ async fn route_request( let stream_result = stream_publisher_body_async( body, &mut streaming_body, - &mut *params, + &mut params, settings, integration_registry, orchestrator, From b03af6b1f94b4bb34b8a59db8b28b69a747287ca Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sun, 10 May 2026 16:34:20 +0530 Subject: [PATCH 42/84] =?UTF-8?q?Fix=20Cache-Control=20headers=20applied?= =?UTF-8?q?=20only=20when=20slots=20matched=20=E2=80=94=20apply=20to=20all?= =?UTF-8?q?=20HTML=20responses=20per=20spec=20=C2=A74.7=20+=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/trusted-server-core/src/publisher.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4a39c962..dc93af76 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -915,7 +915,13 @@ pub async fn handle_publisher_request( None }; - if ad_slots_script.is_some() { + // §4.7: assembled HTML responses must never be shared-cached — per-user bid data + // travels inline. Apply regardless of slot match or auction outcome (§8). + let origin_content_type = response + .get_header(header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .unwrap_or_default(); + if origin_content_type.contains("text/html") { response.set_header(header::CACHE_CONTROL, "private, max-age=0"); response.remove_header("surrogate-control"); response.remove_header("fastly-surrogate-control"); From 349dcdcb2689d039085e1267f0011f8e94f00b2a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sun, 10 May 2026 16:45:54 +0530 Subject: [PATCH 43/84] cargo fmt --- .../src/auction/orchestrator.rs | 111 +++++++++++++----- crates/trusted-server-core/src/publisher.rs | 50 +++++--- 2 files changed, 114 insertions(+), 47 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 953ed6a0..82003105 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -637,7 +637,10 @@ impl AuctionOrchestrator { }; if !provider.is_enabled() { - log::debug!("Provider '{}' is disabled, skipping", provider.provider_name()); + log::debug!( + "Provider '{}' is disabled, skipping", + provider.provider_name() + ); continue; } @@ -656,7 +659,10 @@ impl AuctionOrchestrator { let backend_name = match provider.backend_name(effective_timeout) { Some(name) => name, None => { - log::warn!("Provider '{}' has no backend_name, skipping", provider.provider_name()); + log::warn!( + "Provider '{}' has no backend_name, skipping", + provider.provider_name() + ); continue; } }; @@ -681,7 +687,11 @@ impl AuctionOrchestrator { ); backend_to_provider.insert( backend_name.clone(), - (provider.provider_name().to_string(), start_time, Arc::clone(provider)), + ( + provider.provider_name().to_string(), + start_time, + Arc::clone(provider), + ), ); pending_requests .push(PlatformPendingRequest::new(pending).with_backend_name(backend_name)); @@ -777,28 +787,45 @@ impl AuctionOrchestrator { { let response_time_ms = start_time.elapsed().as_millis() as u64; match platform_response_to_fastly(platform_response) { - Ok(response) => match provider.parse_response(response, response_time_ms) { - Ok(auction_response) => { - log::info!( - "Provider '{}' returned {} bids ({}ms)", - auction_response.provider, - auction_response.bids.len(), - auction_response.response_time_ms - ); - responses.push(auction_response); - } - Err(e) => { - log::warn!("Provider '{}' parse failed: {:?}", provider_name, e); - responses.push(AuctionResponse::error(&provider_name, response_time_ms)); + Ok(response) => { + match provider.parse_response(response, response_time_ms) { + Ok(auction_response) => { + log::info!( + "Provider '{}' returned {} bids ({}ms)", + auction_response.provider, + auction_response.bids.len(), + auction_response.response_time_ms + ); + responses.push(auction_response); + } + Err(e) => { + log::warn!( + "Provider '{}' parse failed: {:?}", + provider_name, + e + ); + responses.push(AuctionResponse::error( + &provider_name, + response_time_ms, + )); + } } - }, + } Err(e) => { - log::warn!("Provider '{}' unsupported body: {:?}", provider_name, e); - responses.push(AuctionResponse::error(&provider_name, response_time_ms)); + log::warn!( + "Provider '{}' unsupported body: {:?}", + provider_name, + e + ); + responses + .push(AuctionResponse::error(&provider_name, response_time_ms)); } } } else { - log::warn!("Received response from unknown backend '{}', ignoring", backend_name); + log::warn!( + "Received response from unknown backend '{}', ignoring", + backend_name + ); } } Err(e) => { @@ -847,18 +874,27 @@ impl AuctionOrchestrator { .wait(PlatformPendingRequest::new(pending)) .await; match platform_resp.change_context(TrustedServerError::Auction { - message: format!("Mediator {} request failed", mediator.provider_name()), + message: format!( + "Mediator {} request failed", + mediator.provider_name() + ), }) { Ok(platform_resp) => { match platform_response_to_fastly(platform_resp).change_context( TrustedServerError::Auction { - message: format!("Mediator {} unsupported body", mediator.provider_name()), + message: format!( + "Mediator {} unsupported body", + mediator.provider_name() + ), }, ) { Ok(response) => { - let response_time_ms = - remaining_ms as u64 - remaining_budget_ms(auction_start, timeout_ms) as u64; - match mediator.parse_response(response, response_time_ms) { + let response_time_ms = remaining_ms as u64 + - remaining_budget_ms(auction_start, timeout_ms) + as u64; + match mediator + .parse_response(response, response_time_ms) + { Ok(mediator_resp) => { let winning = mediator_resp .bids @@ -876,19 +912,30 @@ impl AuctionOrchestrator { } }) .collect(); - let winning = self.apply_floor_prices(winning, &floor_prices); + let winning = self + .apply_floor_prices(winning, &floor_prices); (Some(mediator_resp), winning) } Err(e) => { - log::warn!("Mediator '{}' parse failed: {:?}", mediator.provider_name(), e); - let winning = self.select_winning_bids(&responses, &floor_prices); + log::warn!( + "Mediator '{}' parse failed: {:?}", + mediator.provider_name(), + e + ); + let winning = self.select_winning_bids( + &responses, + &floor_prices, + ); (None, winning) } } } Err(e) => { log::warn!("Mediator body error: {:?}", e); - (None, self.select_winning_bids(&responses, &floor_prices)) + ( + None, + self.select_winning_bids(&responses, &floor_prices), + ) } } } @@ -899,7 +946,11 @@ impl AuctionOrchestrator { } } Err(e) => { - log::warn!("Mediator '{}' failed to dispatch: {:?}", mediator.provider_name(), e); + log::warn!( + "Mediator '{}' failed to dispatch: {:?}", + mediator.provider_name(), + e + ); (None, self.select_winning_bids(&responses, &floor_prices)) } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index dc93af76..3a10e85f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -490,9 +490,17 @@ pub async fn stream_publisher_body_async( // to hold, so delaying the entire body until collection is acceptable. let placeholder = Request::get("https://placeholder.invalid/"); let result = orchestrator - .collect_dispatched_auction(dispatched, services, &make_collect_context(settings, services, &placeholder)) + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), + ) .await; - write_bids_to_state(&result.winning_bids, params.price_granularity, ¶ms.ad_bids_state); + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -629,7 +637,14 @@ async fn one_behind_loop( processor: &mut P, ctx: AuctionCollectCtx<'_>, ) -> Result<(), Report> { - let AuctionCollectCtx { dispatched, price_granularity, ad_bids_state, orchestrator, services, settings } = ctx; + let AuctionCollectCtx { + dispatched, + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + } = ctx; const CHUNK_SIZE: usize = 8192; let mut buffer = vec![0u8; CHUNK_SIZE]; let mut pending: Vec = Vec::new(); @@ -655,9 +670,11 @@ async fn one_behind_loop( }, )?; if !out.is_empty() { - writer.write_all(&out).change_context(TrustedServerError::Proxy { - message: "Failed to write last chunk".to_string(), - })?; + writer + .write_all(&out) + .change_context(TrustedServerError::Proxy { + message: "Failed to write last chunk".to_string(), + })?; } } // Signal EOF to lol_html (fires end() which flushes remaining state). @@ -667,9 +684,11 @@ async fn one_behind_loop( }, )?; if !final_out.is_empty() { - writer.write_all(&final_out).change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), - })?; + writer + .write_all(&final_out) + .change_context(TrustedServerError::Proxy { + message: "Failed to write finalized output".to_string(), + })?; } break; } @@ -682,9 +701,11 @@ async fn one_behind_loop( }, )?; if !out.is_empty() { - writer.write_all(&out).change_context(TrustedServerError::Proxy { - message: "Failed to write chunk".to_string(), - })?; + writer + .write_all(&out) + .change_context(TrustedServerError::Proxy { + message: "Failed to write chunk".to_string(), + })?; } } pending = buffer[..n].to_vec(); @@ -2048,7 +2069,6 @@ mod tests { ad_bids_state: Arc::new(RwLock::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - }; let mut output = Vec::new(); @@ -2094,7 +2114,6 @@ mod tests { ad_bids_state: Arc::new(RwLock::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - }; let mut output = Vec::new(); @@ -2131,7 +2150,6 @@ mod tests { ad_bids_state: Arc::new(RwLock::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - }; let bogus_body = Body::from(b"not gzip".to_vec()); @@ -2235,7 +2253,6 @@ mod tests { ad_bids_state: Arc::new(RwLock::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -2290,7 +2307,6 @@ mod tests { ad_bids_state: Arc::new(RwLock::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - }; let mut output = Vec::new(); From 78885f9c1f53007be16c1c18e480487c6e6a9fc3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sun, 10 May 2026 19:54:52 +0530 Subject: [PATCH 44/84] =?UTF-8?q?Fix=20auction=20consent=20gate=20blocking?= =?UTF-8?q?=20non-GDPR=20regions=20=E2=80=94=20only=20require=20TCF=20Purp?= =?UTF-8?q?ose=201=20when=20gdpr=5Fapplies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/trusted-server-core/src/publisher.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 3a10e85f..81637ae5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -853,10 +853,13 @@ pub async fn handle_publisher_request( Vec::new() }; - let consent_allows_auction = consent_context - .tcf - .as_ref() - .is_some_and(|tcf| tcf.has_purpose_consent(1)); + // Non-GDPR regions (US, etc.) have no TCF string — auction is freely allowed. + // GDPR regions require TCF Purpose 1 (storage/access) before firing. + let consent_allows_auction = !consent_context.gdpr_applies + || consent_context + .tcf + .as_ref() + .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; @@ -1324,10 +1327,11 @@ pub async fn handle_page_bids( .map(|_| services.kv_store()), }); - let consent_allows_auction = consent_context - .tcf - .as_ref() - .is_some_and(|tcf| tcf.has_purpose_consent(1)); + let consent_allows_auction = !consent_context.gdpr_applies + || consent_context + .tcf + .as_ref() + .is_some_and(|tcf| tcf.has_purpose_consent(1)); let winning_bids = if !matched_slots.is_empty() && consent_allows_auction { let mut auction_request = build_auction_request( From 3783e68104258e71b8325820ac1c34026b928fc0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sun, 10 May 2026 20:52:05 +0530 Subject: [PATCH 45/84] =?UTF-8?q?Fix=20SSP=20requests=20using=20placeholde?= =?UTF-8?q?r=20headers=20=E2=80=94=20pass=20real=20request=20to=20dispatch?= =?UTF-8?q?=5Fauction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch_auction was building AuctionContext with a placeholder Request (GET https://placeholder.invalid/) that carried no headers. Prebid's request_bids copies User-Agent, x-forwarded-for, Referer, Accept-Language, and cookies from context.request before sending to Prebid Server, so SSPs received stripped requests and returned empty bids. Fix: dispatch SSP requests before req.send_async(), using the original request directly as AuctionContext.request. DispatchedAuction holds no lifetime reference to Request, so the borrow ends at return and req can be modified (restrict_accept_encoding, Host header) and sent to origin immediately after. --- crates/trusted-server-core/src/publisher.rs | 36 +++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 81637ae5..f5caa571 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -872,25 +872,16 @@ pub async fn handle_publisher_request( 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); - - // Dispatch origin request first. - let pending_origin = - req.send_async(&backend_name) - .change_context(TrustedServerError::Proxy { - message: "Failed to dispatch async origin request".to_string(), - })?; - - // Dispatch SSP bid requests BEFORE awaiting origin — all HTTP is now in-flight - // in Fastly's native layer. WASM yields only for origin (fast, cache-hit path), - // so TTFB ≈ origin latency instead of TTFB ≈ auction timeout. let price_granularity = settings .creative_opportunities .as_ref() .map(|co| co.price_granularity) .unwrap_or_default(); + + // Dispatch SSP bid requests while req still has the original client headers + // (User-Agent, x-forwarded-for, cookies, etc.). The borrow ends when + // dispatch_auction returns — DispatchedAuction holds no lifetime — so req + // can be mutated and sent to origin immediately after. let dispatched_auction = if should_run_auction { let co_config = settings .creative_opportunities @@ -903,10 +894,9 @@ pub async fn handle_publisher_request( &request_info, co_config, ); - let placeholder_req = fastly::Request::get("https://placeholder.invalid/"); let auction_context = AuctionContext { settings, - request: &placeholder_req, + request: &req, client_info: services.client_info(), timeout_ms: auction_timeout_ms, provider_responses: None, @@ -917,7 +907,19 @@ pub async fn handle_publisher_request( None }; - // Now yield for origin — SSP requests are already racing in Fastly's native layer. + // Only advertise encodings the rewrite pipeline can decode and re-encode. + restrict_accept_encoding(&mut req); + req.set_header("host", &origin_host); + + // Dispatch origin — SSP requests are already racing in Fastly's native layer. + // TTFB ≈ origin latency instead of TTFB ≈ auction timeout. + let pending_origin = + req.send_async(&backend_name) + .change_context(TrustedServerError::Proxy { + message: "Failed to dispatch async origin request".to_string(), + })?; + + // Now yield for origin. let mut response = pending_origin .wait() .change_context(TrustedServerError::Proxy { From 0c9465206f4e6b3ad865277dae63378090831a79 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 12:36:25 +0530 Subject: [PATCH 46/84] Fix async auction collect abandoning SSP bids when origin is slow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In collect_dispatched_auction, the select loop checked `auction_start.elapsed() >= deadline` after each SSP response and broke early if the 1500ms budget had elapsed. When origin TTFB + body download exceeded the auction budget, the check fired after collecting the first SSP response, abandoning the second SSP's already-buffered response. This left responses with only one (possibly errored) SSP, causing remaining_ms == 0 which skipped the mediator, and select_winning_bids on the partial set returned zero bids. The deadline break is wrong in this context: SSP HTTP connections are already bounded by the backend first_byte_timeout set at dispatch time (1000ms per provider). By the time collect is called at origin EOF, all SSPs have either responded or been errored by Fastly's host. The select() calls drain instantly — no WASM-level deadline enforcement is needed or safe. Also add info-level log statements at dispatch, collect, and write_bids_to_state to make the auction pipeline observable without requiring a dashboard. --- .../src/auction/orchestrator.rs | 10 --------- crates/trusted-server-core/src/publisher.rs | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 82003105..867bdf3a 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -751,8 +751,6 @@ impl AuctionOrchestrator { request, } = dispatched; - let deadline = Duration::from_millis(u64::from(timeout_ms)); - log::info!( "Collecting {} in-flight SSP responses (timeout: {}ms remaining: {}ms)", pending_requests.len(), @@ -833,14 +831,6 @@ impl AuctionOrchestrator { } } - if auction_start.elapsed() >= deadline && !remaining.is_empty() { - log::warn!( - "Auction timeout ({}ms) reached, dropping {} remaining request(s)", - timeout_ms, - remaining.len() - ); - break; - } } let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index f5caa571..5284e20f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -561,6 +561,15 @@ pub(crate) fn write_bids_to_state( price_granularity: PriceGranularity, ad_bids_state: &Arc>>, ) { + log::info!( + "write_bids_to_state: {} winning bid(s): [{}]", + winning_bids.len(), + winning_bids + .keys() + .cloned() + .collect::>() + .join(", ") + ); let bid_map = build_bid_map(winning_bids, price_granularity); let bids_script = build_bids_script(&bid_map); *ad_bids_state.write().expect("should write bid state") = Some(bids_script); @@ -655,11 +664,16 @@ async fn one_behind_loop( // Origin exhausted — pending holds the last chunk. // Collect the auction before feeding it to lol_html so that // the handler sees populated ad_bids_state. + log::info!("one_behind_loop: EOF — collecting dispatched auction"); let placeholder = Request::get("https://placeholder.invalid/"); let collect_ctx = make_collect_context(settings, services, &placeholder); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; + log::info!( + "one_behind_loop: collect complete — {} winning bid(s)", + result.winning_bids.len() + ); write_bids_to_state(&result.winning_bids, price_granularity, ad_bids_state); // Process the held last chunk (not is_last — finalization is separate). @@ -906,6 +920,14 @@ pub async fn handle_publisher_request( } else { None }; + log::info!( + "dispatch_auction: {}", + if dispatched_auction.is_some() { + "Some — auction running async" + } else { + "None — falling back to sync or skipped" + } + ); // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); From f172f4477c60ae0fc0e0d68b0d9f969652dc092f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 13:15:23 +0530 Subject: [PATCH 47/84] Cargo fmt --- crates/trusted-server-core/src/auction/orchestrator.rs | 1 - crates/trusted-server-core/src/publisher.rs | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 867bdf3a..e9d8fa19 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -830,7 +830,6 @@ impl AuctionOrchestrator { log::warn!("A provider request failed during collection: {:?}", e); } } - } let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5284e20f..3a9eb089 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -564,11 +564,7 @@ pub(crate) fn write_bids_to_state( log::info!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), - winning_bids - .keys() - .cloned() - .collect::>() - .join(", ") + winning_bids.keys().cloned().collect::>().join(", ") ); let bid_map = build_bid_map(winning_bids, price_granularity); let bids_script = build_bids_script(&bid_map); From 14cd493ee907fbf2cce5030062bfed4ccac41043 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 13:32:28 +0530 Subject: [PATCH 48/84] Fix mediator always skipped when origin body exceeds SSP auction budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In collect_dispatched_auction, the mediator was skipped when remaining_budget_ms(auction_start, timeout_ms) == 0. In the async-dispatch path, auction_start is set before pending_origin.wait(), so elapsed time includes the full origin TTFB and body download. For heavy SSR pages (autoblog), this exceeds the 1500ms SSP budget, making remaining_ms == 0 at every collection and causing the mediator to be permanently skipped. The mediator (adserver_mock) is the primary bid source — SSPs alone return no bids. Skipping it means window.__ts_bids == {} on every full page load, while handle_page_bids (which uses the sequential run_auction path) works correctly because it measures remaining time from after SSP collection. Fix: give the mediator its own configured timeout (mediator.timeout_ms()) instead of the exhausted SSP budget. This mirrors how run_parallel_mediation works: the mediator's deadline is independent of SSP round-trip time. Side effect: mediator backend name is now stable (always t1000 for adserver_mock) rather than varying per request with remaining_ms. --- .../src/auction/orchestrator.rs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index e9d8fa19..892ff9eb 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -835,24 +835,25 @@ impl AuctionOrchestrator { let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { match self.providers.get(mediator_name.as_str()) { Some(mediator) => { - let remaining_ms = remaining_budget_ms(auction_start, timeout_ms); - if remaining_ms == 0 { - log::warn!("Auction timeout exhausted during bidding — skipping mediator"); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - }; - } + // Use the mediator's own configured timeout, not the remaining SSP + // budget. In the async-dispatch path, SSPs race against origin, so + // auction_start.elapsed() can exceed the SSP budget by the time the + // origin body finishes streaming. Skipping the mediator in that case + // would discard all bids — the mediator is the primary bid source. + let mediator_timeout = mediator.timeout_ms(); + let mediator_start = Instant::now(); + log::info!( + "Running mediator '{}' with {}ms budget (SSP budget remaining: {}ms)", + mediator.provider_name(), + mediator_timeout, + remaining_budget_ms(auction_start, timeout_ms), + ); let placeholder = fastly::Request::get("https://placeholder.invalid/"); let mediator_context = AuctionContext { settings: context.settings, request: &placeholder, client_info: context.client_info, - timeout_ms: remaining_ms, + timeout_ms: mediator_timeout, provider_responses: Some(&responses), services: context.services, }; @@ -878,9 +879,8 @@ impl AuctionOrchestrator { }, ) { Ok(response) => { - let response_time_ms = remaining_ms as u64 - - remaining_budget_ms(auction_start, timeout_ms) - as u64; + let response_time_ms = + mediator_start.elapsed().as_millis() as u64; match mediator .parse_response(response, response_time_ms) { From 6210ebbf1560558813c7d30865b032a5a7962649 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 14:04:28 +0530 Subject: [PATCH 49/84] Cargo fmt --- crates/trusted-server-core/src/publisher.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6fd9b0d6..30abc532 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1332,7 +1332,8 @@ pub async fn handle_page_bids( .collect(); let http_req = compat::from_fastly_headers_ref(&req); - let request_info = crate::http_util::RequestInfo::from_request(&http_req, &services.client_info); + let request_info = + crate::http_util::RequestInfo::from_request(&http_req, &services.client_info); let cookie_jar = handle_request_cookies(&http_req)?; let ec_id = get_or_generate_ec_id_from_http_request(settings, services, &http_req)?; let geo = services From 3cdf9952f54fcfe4c4c7cc87f8064aa1af8aa721 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 14:44:58 +0530 Subject: [PATCH 50/84] Adding debug info for auction --- crates/trusted-server-core/src/publisher.rs | 44 +++++++++++++++------ crates/trusted-server-core/src/settings.rs | 6 +++ trusted-server.toml | 6 ++- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 30abc532..ba594d80 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -677,6 +677,29 @@ async fn one_behind_loop( ); write_bids_to_state(&result.winning_bids, price_granularity, ad_bids_state); + if settings.debug.auction_html_comment { + let ssp_count = result.provider_responses.len(); + let mediator_info = match &result.mediator_response { + Some(r) => format!("ok({}_bids)", r.bids.len()), + None => "none".to_string(), + }; + let debug_comment = format!( + "", + result.winning_bids.len() + ); + let mut state = ad_bids_state + .write() + .expect("should write bid state for debug"); + match &mut *state { + Some(script) => { + *script = format!("{debug_comment}\n{script}"); + } + None => { + *state = Some(debug_comment); + } + } + } + // Process the held last chunk (not is_last — finalization is separate). if !pending.is_empty() { let out = processor.process_chunk(&pending, false).change_context( @@ -909,6 +932,7 @@ pub async fn handle_publisher_request( &ec_id, &consent_context, &request_info, + &request_path, co_config, ); let auction_context = AuctionContext { @@ -1109,18 +1133,23 @@ pub(crate) fn build_auction_request( ec_id: &str, consent_context: &crate::consent::ConsentContext, request_info: &crate::http_util::RequestInfo, + request_path: &str, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, ) -> AuctionRequest { let slots = matched_slots .iter() .map(|s| s.to_ad_slot(&co_config.gam_network_id)) .collect(); + let page_url = format!( + "{}://{}{}", + request_info.scheme, request_info.host, request_path + ); AuctionRequest { id: format!("ts-{}", ec_id), slots, publisher: PublisherInfo { domain: request_info.host.clone(), - page_url: None, + page_url: Some(page_url.clone()), }, user: UserInfo { id: ec_id.to_string(), @@ -1130,7 +1159,7 @@ pub(crate) fn build_auction_request( device: None, site: Some(SiteInfo { domain: request_info.host.clone(), - page: String::new(), + page: page_url, }), context: std::collections::HashMap::new(), } @@ -1363,21 +1392,14 @@ pub async fn handle_page_bids( .is_some_and(|tcf| tcf.has_purpose_consent(1)); let winning_bids = if !matched_slots.is_empty() && consent_allows_auction { - let mut auction_request = build_auction_request( + let auction_request = build_auction_request( &matched_slots, &ec_id, &consent_context, &request_info, + &path_param, co_config, ); - let page_url = format!( - "{}://{}{}", - request_info.scheme, request_info.host, path_param - ); - auction_request.publisher.page_url = Some(page_url.clone()); - if let Some(ref mut site) = auction_request.site { - site.page = page_url; - } let timeout_ms = co_config .auction_timeout_ms .unwrap_or(settings.auction.timeout_ms); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index e09c50e2..386f0d54 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -410,6 +410,12 @@ pub struct DebugConfig { /// Fastly-observed TLS details that browser JS cannot normally read. #[serde(default)] pub ja4_endpoint_enabled: bool, + + /// Inject a `` HTML comment before `` showing + /// auction pipeline stats (SSP count, mediator status, winning bid count). + /// Never enable in production — visible in page source. + #[serde(default)] + pub auction_html_comment: bool, } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] diff --git a/trusted-server.toml b/trusted-server.toml index 60876389..a71abfdd 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -208,7 +208,11 @@ endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" timeout_ms = 1000 # Debug configuration (all flags default to false — do not enable in production) -# [debug] +# TODO: remove [debug] block before merging to main +[debug] +# Inject before . +# Visible in page source. Disable after investigation. +auction_html_comment = true # Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. # Returns a plain-text response with the following fields (Fastly-observed values): # ja4 — JA4 TLS client fingerprint From e35c593f3b26f64ee148ed39a698b19c23b594db Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 15:30:15 +0530 Subject: [PATCH 51/84] Fix auction bids missing on Next.js buffered HTML path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classify_response_route` returns `BufferedProcessed` when HTML has post-processors registered (e.g. the Next.js integration registers one via `with_html_post_processor`). Unlike the `Stream` path, which drives `one_behind_loop` to collect the dispatched auction at origin EOF, the `BufferedProcessed` branch previously discarded `dispatched_auction` entirely — so `ad_bids_state` stayed `None` and lol_html injected the fallback `window.__ts_bids = {}` instead of real bids. Fix: collect the in-flight dispatched auction in the `BufferedProcessed` branch before calling `process_response_streaming`, using the same `collect_dispatched_auction` + `write_bids_to_state` pattern that the stream path uses. The `debug.auction_html_comment` injection is mirrored here as well so the comment appears in both code paths when enabled. --- crates/trusted-server-core/src/publisher.rs | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ba594d80..0178d56d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1103,6 +1103,49 @@ pub async fn handle_publisher_request( content_type, content_encoding, request_host, origin_host ); + // Collect any in-flight auction before processing buffered HTML. + // BufferedProcessed is taken when HTML has post-processors (e.g. Next.js rewriters). + // Unlike the Stream path, the body is fully buffered first — collect auction + // now so bids are available when the handler fires. + if let Some(dispatched) = dispatched_auction { + let placeholder = fastly::Request::get("https://placeholder.invalid/"); + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), + ) + .await; + log::info!( + "BufferedProcessed: auction collected — {} winning bid(s)", + result.winning_bids.len() + ); + write_bids_to_state(&result.winning_bids, price_granularity, &ad_bids_state); + + if settings.debug.auction_html_comment { + let ssp_count = result.provider_responses.len(); + let mediator_info = match &result.mediator_response { + Some(r) => format!("ok({}_bids)", r.bids.len()), + None => "none".to_string(), + }; + let debug_comment = format!( + "", + result.winning_bids.len() + ); + let mut state = ad_bids_state + .write() + .expect("should write bid state for debug"); + match &mut *state { + Some(script) => { + *script = format!("{debug_comment}\n{script}"); + } + None => { + *state = Some(debug_comment); + } + } + } + } + let body = response.take_body(); let params = ProcessResponseParams { content_encoding: &content_encoding, From 3dac7760f6361d022e831137b37314084d3687b9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 15:57:54 +0530 Subject: [PATCH 52/84] Add path label and auction time to debug HTML comment --- crates/trusted-server-core/src/publisher.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 0178d56d..21399ef7 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -684,8 +684,9 @@ async fn one_behind_loop( None => "none".to_string(), }; let debug_comment = format!( - "", - result.winning_bids.len() + "", + result.winning_bids.len(), + result.total_time_ms, ); let mut state = ad_bids_state .write() @@ -1129,8 +1130,9 @@ pub async fn handle_publisher_request( None => "none".to_string(), }; let debug_comment = format!( - "", - result.winning_bids.len() + "", + result.winning_bids.len(), + result.total_time_ms, ); let mut state = ad_bids_state .write() From 65c0ad3090427a84e3655d81c01ff13c7079472f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 11 May 2026 19:53:15 +0530 Subject: [PATCH 53/84] Fix XSS in script injection and cap mediator at A_deadline html_escape_for_script now unicode-escapes <, >, & and U+2028/2029 in addition to \ and ". These characters allow a crafted bid value to break out of the ` injection breaking out of the script context +/// - U+2028, U+2029 — line/paragraph separators that are valid JSON but terminate +/// a JS string literal in some parsers +/// +/// All substitutions use `\uXXXX` form, which is valid inside both JSON strings +/// and JS string literals. The result is always safe to write as `JSON.parse("…")`. fn html_escape_for_script(s: &str) -> String { - s.replace('\\', "\\\\").replace('"', "\\\"") + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('<', "\\u003C") + .replace('>', "\\u003E") + .replace('&', "\\u0026") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029") } /// Build a price-bucketed bid map from winning bids. @@ -2633,6 +2645,26 @@ mod tests { "both\\\\\\\"mixed", "should escape both backslashes and quotes" ); + assert_eq!( + html_escape_for_script(""), + "\\u003Cscript\\u003Ealert(1)\\u003C/script\\u003E", + "should unicode-escape angle brackets to prevent script injection" + ); + assert_eq!( + html_escape_for_script("a&b"), + "a\\u0026b", + "should unicode-escape ampersand" + ); + assert_eq!( + html_escape_for_script("line\u{2028}sep"), + "line\\u2028sep", + "should unicode-escape U+2028 line separator" + ); + assert_eq!( + html_escape_for_script("para\u{2029}sep"), + "para\\u2029sep", + "should unicode-escape U+2029 paragraph separator" + ); } } } From 0f67a8d9d24368513a7ba68a9fa8e7cf3df20f9f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 13 May 2026 14:36:58 +0530 Subject: [PATCH 54/84] Added footer slot id --- creative-opportunities.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/creative-opportunities.toml b/creative-opportunities.toml index 3cd27f2b..95ea849a 100644 --- a/creative-opportunities.toml +++ b/creative-opportunities.toml @@ -38,3 +38,22 @@ slot_id = "aps-slot-homepage-header" [slot.providers.pbs.bidders] mocktioneer = { bid = 2.00 } criteo = { networkId = 123456, pubid = "123456" } + +[[slot]] +id = "homepage_footer_ad" +gam_unit_path = "/88059007/autoblog/homepage" +div_id = "ad-fixed_bottom-0-_R_klubtak5lb_" +page_patterns = ["/"] +formats = [{ width = 728, height = 90 }] +floor_price = 0.50 + +[slot.targeting] +pos = "btf" +zone = "fixedBottom" + +[slot.providers.aps] +slot_id = "aps-slot-homepage-footer" + +[slot.providers.pbs.bidders] +mocktioneer = { bid = 1.50 } +criteo = { networkId = 123456, pubid = "123456" } From a7e87512c3bfe7b1fe0296a45e624edfb2498f0a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 14 May 2026 15:37:11 +0530 Subject: [PATCH 55/84] Fix page-bids auction context, protect Cache-Control from operator override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass the real incoming request to AuctionContext in handle_page_bids instead of a placeholder — SSPs now receive browser UA, referer, and cookies on SPA navigation bids. Guard Cache-Control in finalize_response so operator response_headers cannot overwrite the private/no-store directives set for per-user HTML and page-bids responses. Disable auction_html_comment debug flag in trusted-server.toml. --- crates/trusted-server-adapter-fastly/src/main.rs | 10 ++++++++++ crates/trusted-server-core/src/publisher.rs | 3 +-- trusted-server.toml | 3 +-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index fc66fcfd..24c447d3 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -404,6 +404,16 @@ fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: } for (key, value) in &settings.response_headers { + // Never overwrite a privacy-critical Cache-Control header (private, no-store, etc.) + // that was set for per-user responses (HTML or page-bids). + if **key == header::CACHE_CONTROL + && response + .get_header(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("private")) + { + continue; + } response.set_header(key, value); } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 30955f91..5ab8d1ae 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1460,10 +1460,9 @@ pub async fn handle_page_bids( let timeout_ms = co_config .auction_timeout_ms .unwrap_or(settings.auction.timeout_ms); - let placeholder_req = fastly::Request::get("https://placeholder.invalid/"); let auction_context = AuctionContext { settings, - request: &placeholder_req, + request: &req, client_info: services.client_info(), timeout_ms, provider_responses: None, diff --git a/trusted-server.toml b/trusted-server.toml index a71abfdd..899c8c89 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -208,11 +208,10 @@ endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" timeout_ms = 1000 # Debug configuration (all flags default to false — do not enable in production) -# TODO: remove [debug] block before merging to main [debug] # Inject before . # Visible in page source. Disable after investigation. -auction_html_comment = true +# auction_html_comment = true # Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. # Returns a plain-text response with the following fields (Fastly-observed values): # ja4 — JA4 TLS client fingerprint From 401136378c6026aa445366b8c0225f132e90ab5a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 14 May 2026 16:14:24 +0530 Subject: [PATCH 56/84] Restore nurl/burl/ad_id through adserver_mock mediation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock mediator endpoint does not echo nurl/burl/ad_id back in its response. Build a bid index in request_bids keyed by (provider, slot_id, bidder) — where bidder is recovered from the echoed crid field — and restore the fields in parse_mediation_response from the original SSP bids. Fixes the spec requirement: both nurl and burl must travel in __ts_bids for client-side sendBeacon firing on slotRenderEnded (§4.5). --- .../src/integrations/adserver_mock.rs | 81 +++++++++++++++---- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 3a42ec2a..c8d0ca7b 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -10,7 +10,7 @@ use fastly::Request; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; use std::collections::{BTreeMap, HashMap}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use validator::Validate; @@ -88,16 +88,28 @@ impl IntegrationConfig for AdServerMockConfig { // Provider // ============================================================================ +/// Lookup index built from original SSP bids during `request_bids`, consumed +/// during `parse_response` to restore `nurl`/`burl`/`ad_id` that the mock +/// mediator endpoint does not echo back. +/// +/// Keyed by `(provider_name, slot_id, bidder_name)`. +type BidIndex = HashMap<(String, String, String), Bid>; + /// Mock ad server mediator provider. pub struct AdServerMockProvider { config: AdServerMockConfig, + /// Bridges SSP bid metadata (nurl/burl/ad_id) from request_bids to parse_response. + bid_index: Mutex>, } impl AdServerMockProvider { /// Create a new mock ad server provider. #[must_use] pub fn new(config: AdServerMockConfig) -> Self { - Self { config } + Self { + config, + bid_index: Mutex::new(None), + } } /// Build the mediation endpoint URL, appending context values as query @@ -212,8 +224,17 @@ impl AdServerMockProvider { /// Parse `OpenRTB` response from mediation endpoint. /// Mediation returns decoded prices for all bids (including APS bids that were encoded). - fn parse_mediation_response(&self, json: &Json, response_time_ms: u64) -> AuctionResponse { - // Parse OpenRTB response + /// + /// `bid_index` is the SSP-bid lookup built in `request_bids`. The mock mediator + /// does not echo `nurl`/`burl`/`ad_id` back, so they are restored from the index + /// using `(seat, impid, bidder)` where bidder is recovered from the echoed `crid` + /// field (`"{bidder}-creative"` format set during request construction). + fn parse_mediation_response( + &self, + json: &Json, + response_time_ms: u64, + bid_index: &BidIndex, + ) -> AuctionResponse { let empty_array = vec![]; let seatbid = json["seatbid"].as_array().unwrap_or(&empty_array); @@ -225,10 +246,18 @@ impl AdServerMockProvider { let bids = seat["bid"].as_array().unwrap_or(&empty_bids); for bid in bids { - // Mediation layer returns decoded prices for all bids + let slot_id = bid["impid"].as_str().unwrap_or("").to_string(); + + // Recover bidder name from crid ("{bidder}-creative") to look up the + // original SSP bid and restore nurl/burl/ad_id the mediator drops. + let crid = bid["crid"].as_str().unwrap_or(""); + let bidder = crid.strip_suffix("-creative").unwrap_or(""); + let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string()); + let original = bid_index.get(&key); + all_bids.push(Bid { - slot_id: bid["impid"].as_str().unwrap_or("").to_string(), - price: bid["price"].as_f64(), // Now properly decoded by mediation + slot_id, + price: bid["price"].as_f64(), currency: "USD".to_string(), creative: bid["adm"].as_str().map(String::from), width: bid["w"].as_u64().unwrap_or(0) as u32, @@ -239,9 +268,9 @@ impl AdServerMockProvider { .filter_map(|v| v.as_str().map(String::from)) .collect() }), - nurl: None, - burl: None, - ad_id: None, + nurl: original.and_then(|b| b.nurl.clone()), + burl: original.and_then(|b| b.burl.clone()), + ad_id: original.and_then(|b| b.ad_id.clone()), metadata: HashMap::new(), }); } @@ -274,6 +303,19 @@ impl AuctionProvider for AdServerMockProvider { bidder_responses.len() ); + // Build bid index so parse_response can restore nurl/burl/ad_id from + // the original SSP bids (the mock mediator does not echo these fields). + let mut index = BidIndex::new(); + for response in bidder_responses { + for bid in &response.bids { + index.insert( + (response.provider.clone(), bid.slot_id.clone(), bid.bidder.clone()), + bid.clone(), + ); + } + } + *self.bid_index.lock().expect("should lock bid index") = Some(index); + // Build mediation request let mediation_req = self .build_mediation_request(request, bidder_responses) @@ -349,7 +391,15 @@ impl AuctionProvider for AdServerMockProvider { log::trace!("AdServer Mock response: {:?}", response_json); - let auction_response = self.parse_mediation_response(&response_json, response_time_ms); + let bid_index = self + .bid_index + .lock() + .expect("should lock bid index") + .take() + .unwrap_or_default(); + + let auction_response = + self.parse_mediation_response(&response_json, response_time_ms, &bid_index); log::info!( "AdServer Mock returned {} bids in {}ms", @@ -571,7 +621,8 @@ mod tests { "cur": "USD" }); - let auction_response = provider.parse_mediation_response(&mediation_response, 200); + let auction_response = + provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new()); assert_eq!(auction_response.provider, "adserver_mock"); assert_eq!(auction_response.status, BidStatus::Success); @@ -597,7 +648,8 @@ mod tests { "cur": "USD" }); - let auction_response = provider.parse_mediation_response(&mediation_response, 100); + let auction_response = + provider.parse_mediation_response(&mediation_response, 100, &BidIndex::new()); assert_eq!(auction_response.status, BidStatus::NoBid); assert_eq!(auction_response.bids.len(), 0); @@ -791,7 +843,8 @@ mod tests { "cur": "USD" }); - let auction_response = provider.parse_mediation_response(&mediation_response, 200); + let auction_response = + provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new()); assert_eq!(auction_response.status, BidStatus::Success); assert_eq!(auction_response.bids.len(), 2); From 8516caa8e5cd5369755f5edad665311f98fca2dd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 14 May 2026 16:17:40 +0530 Subject: [PATCH 57/84] Populate device.user_agent in auction request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APS reads user agent from request.device — without it, real APS bids arrive with wrong or missing device targeting. Pass the incoming UA from both the page-load and page-bids auction paths. --- crates/trusted-server-core/src/publisher.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5ab8d1ae..18fc62c8 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -20,7 +20,7 @@ use fastly::{Body, Request, Response}; use crate::auction::orchestrator::{AuctionOrchestrator, DispatchedAuction}; use crate::auction::types::{ - AuctionContext, AuctionRequest, Bid, PublisherInfo, SiteInfo, UserInfo, + AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; use crate::backend::BackendConfig; use crate::compat; @@ -935,6 +935,7 @@ pub async fn handle_publisher_request( &request_info, &request_path, co_config, + req.get_header_str("user-agent"), ); let auction_context = AuctionContext { settings, @@ -1180,6 +1181,7 @@ pub(crate) fn build_auction_request( request_info: &crate::http_util::RequestInfo, request_path: &str, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + user_agent: Option<&str>, ) -> AuctionRequest { let slots = matched_slots .iter() @@ -1201,7 +1203,11 @@ pub(crate) fn build_auction_request( fresh_id: ec_id.to_string(), consent: Some(consent_context.clone()), }, - device: None, + device: user_agent.filter(|ua| !ua.is_empty()).map(|ua| DeviceInfo { + user_agent: Some(ua.to_string()), + ip: None, + geo: None, + }), site: Some(SiteInfo { domain: request_info.host.clone(), page: page_url, @@ -1456,6 +1462,7 @@ pub async fn handle_page_bids( &request_info, &path_param, co_config, + req.get_header_str("user-agent"), ); let timeout_ms = co_config .auction_timeout_ms From 9e0ec5ba566dd925e01dc967b265142b37e782fb Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 14 May 2026 16:31:42 +0530 Subject: [PATCH 58/84] Fix __tsAdInit fallback: look up bids by slot id not div id slotRenderEnded gives a div element id via getSlotElementId(), but __ts_bids is keyed by slot id. Build a divToSlotId map during slot setup (matching the TS implementation) and use it in the event handler. Without this, nurl/burl beacons and hb_adid match checks silently fail in the server-rendered fallback whenever div_id != id. --- crates/trusted-server-core/src/integrations/gpt.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 796d633e..690ba048 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -447,22 +447,24 @@ impl IntegrationHeadInjector for GptIntegration { "window.__tsAdInit=function(){", "var slots=window.__ts_ad_slots||[];", "var bids=window.__ts_bids||{};", + "var divToSlotId={};", "googletag.cmd.push(function(){", - "var gptSlots=slots.map(function(slot){", + "slots.map(function(slot){", "var s=googletag.defineSlot(slot.gam_unit_path,slot.formats,slot.div_id);", - "if(!s)return null;", + "if(!s)return;", "s.addService(googletag.pubads());", "Object.entries(slot.targeting||{}).forEach(function(e){s.setTargeting(e[0],e[1]);});", "var b=bids[slot.id]||{};", "[\"hb_pb\",\"hb_bidder\",\"hb_adid\"].forEach(function(k){if(b[k])s.setTargeting(k,b[k]);});", "s.setTargeting(\"ts_initial\",\"1\");", - "return{id:slot.id,gptSlot:s};", - "}).filter(Boolean);", + "divToSlotId[slot.div_id]=slot.id;", + "});", "googletag.pubads().enableSingleRequest();", "googletag.enableServices();", "googletag.pubads().addEventListener(\"slotRenderEnded\",function(ev){", - "var id=ev.slot.getSlotElementId();", - "var b=bids[id]||{};", + "var divId=ev.slot.getSlotElementId();", + "var slotId=divToSlotId[divId]||divId;", + "var b=bids[slotId]||{};", "var ourBidWon=!ev.isEmpty&&b.hb_adid&&ev.slot.getTargeting(\"hb_adid\")[0]===b.hb_adid;", "if(ourBidWon){", "if(b.nurl)navigator.sendBeacon(b.nurl);", From d27a329919e48ea35afc66ad91115f898a3fd10c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 14 May 2026 16:32:12 +0530 Subject: [PATCH 59/84] Format lint using cargo fmt --- .../trusted-server-core/src/integrations/adserver_mock.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index c8d0ca7b..a8f7cadf 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -309,7 +309,11 @@ impl AuctionProvider for AdServerMockProvider { for response in bidder_responses { for bid in &response.bids { index.insert( - (response.provider.clone(), bid.slot_id.clone(), bid.bidder.clone()), + ( + response.provider.clone(), + bid.slot_id.clone(), + bid.bidder.clone(), + ), bid.clone(), ); } From 790c1232f0efa050eff1ecc5c84a7cd9307a78ea Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 14 May 2026 16:44:22 +0530 Subject: [PATCH 60/84] Fix clippy doc-markdown lint in adserver_mock --- crates/trusted-server-core/src/integrations/adserver_mock.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index a8f7cadf..4330ea66 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -98,7 +98,7 @@ type BidIndex = HashMap<(String, String, String), Bid>; /// Mock ad server mediator provider. pub struct AdServerMockProvider { config: AdServerMockConfig, - /// Bridges SSP bid metadata (nurl/burl/ad_id) from request_bids to parse_response. + /// Bridges SSP bid metadata (`nurl`/`burl`/`ad_id`) from `request_bids` to `parse_response`. bid_index: Mutex>, } From 299f6ba95704dcf0b35a56f2f28d74a7075c6a55 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 15 May 2026 15:59:24 +0530 Subject: [PATCH 61/84] Remove inline PBS bidder params from creative-opportunities.toml PBS bidder credentials (mocktioneer, criteo placeholder params) were being sent directly to PBS on every auction request. Per the design spec, PBS bidder params belong in PBS stored requests keyed by slot ID, not in the edge config file. Removes PbsSlotParams struct, SlotProviders.pbs field, the to_ad_slot wiring block, and the corresponding test. Slots without inline bidder params trigger the existing storedrequest fallback path in the Prebid provider. Closes #697 --- .../src/creative_opportunities.rs | 65 ------------------- creative-opportunities.toml | 12 ---- 2 files changed, 77 deletions(-) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 12957d4b..25add829 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -125,11 +125,6 @@ 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 @@ -175,8 +170,6 @@ 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. @@ -186,24 +179,6 @@ 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 { @@ -333,46 +308,6 @@ 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(serde_json::Value::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(serde_json::Value::as_i64), - Some(123456), - "should wire criteo networkId param" - ); - } - #[test] fn to_ad_slot_sets_floor_price_and_formats() { let slot = make_slot("atf", vec!["/"]); diff --git a/creative-opportunities.toml b/creative-opportunities.toml index 95ea849a..b6ed8900 100644 --- a/creative-opportunities.toml +++ b/creative-opportunities.toml @@ -16,10 +16,6 @@ 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" @@ -35,10 +31,6 @@ zone = "header" [slot.providers.aps] slot_id = "aps-slot-homepage-header" -[slot.providers.pbs.bidders] -mocktioneer = { bid = 2.00 } -criteo = { networkId = 123456, pubid = "123456" } - [[slot]] id = "homepage_footer_ad" gam_unit_path = "/88059007/autoblog/homepage" @@ -53,7 +45,3 @@ zone = "fixedBottom" [slot.providers.aps] slot_id = "aps-slot-homepage-footer" - -[slot.providers.pbs.bidders] -mocktioneer = { bid = 1.50 } -criteo = { networkId = 123456, pubid = "123456" } From 03d39f29bc2d643c25b9d85ccbae9fc304ee5d5c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 15 May 2026 16:16:51 +0530 Subject: [PATCH 62/84] Clarify and test APS floor price enforcement in mediation path - Rewrite misleading comment in apply_floor_prices: price=None bids pass through in the parallel-only path because decoding is deferred; in the mediation path the mediator decodes prices before this function runs - Add test: decoded APS bid below slot floor is dropped - Add test: decoded APS bid at or above slot floor is kept Closes #698 --- .../src/auction/orchestrator.rs | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 17fe405d..58f46b14 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -542,7 +542,11 @@ impl AuctionOrchestrator { let starting_count = winning_bids.len(); winning_bids.retain(|slot_id, bid| match floor_prices.get(slot_id) { Some(floor) => { - // Bids without price (e.g., APS) pass through - floor checked in mediation + // price=None means the SSP returned an encoded price (e.g. APS amznbid). + // In the parallel-only path this bid cannot yet be floor-checked; it passes + // through and will be decoded (and re-checked) by the mediation layer. + // In the mediation path, mediation decodes prices before calling this + // function, so any bid still carrying price=None is dropped upstream. match bid.price { Some(price) if price >= *floor => true, Some(_) => { @@ -554,7 +558,7 @@ impl AuctionOrchestrator { } None => { log::debug!( - "Passing bid with encoded price for slot '{}' - floor check deferred to mediation", + "Passing encoded-price bid for slot '{}' - price not yet decoded", slot_id ); true @@ -1305,4 +1309,79 @@ mod tests { "Price should still be None (not decoded yet)" ); } + + #[test] + fn test_apply_floor_prices_drops_decoded_aps_bid_below_floor() { + // After mediation decodes an APS bid, apply_floor_prices must enforce the + // slot floor on the resulting price=Some(x) value. This test simulates the + // state of a bid after mediator decoding: price is Some, amznbid is gone. + let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); + let mut floor_prices = HashMap::new(); + floor_prices.insert("atf".to_string(), 0.50); + + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf".to_string(), + Bid { + slot_id: "atf".to_string(), + price: Some(0.30), // decoded APS price — below $0.50 floor + currency: "USD".to_string(), + creative: Some("
APS Ad
".to_string()), + adomain: None, + bidder: "aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + metadata: HashMap::new(), + }, + ); + + let filtered = orchestrator.apply_floor_prices(winning_bids, &floor_prices); + + assert!( + filtered.is_empty(), + "Decoded APS bid below slot floor should be dropped" + ); + } + + #[test] + fn test_apply_floor_prices_keeps_decoded_aps_bid_at_or_above_floor() { + let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); + let mut floor_prices = HashMap::new(); + floor_prices.insert("atf".to_string(), 0.50); + + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf".to_string(), + Bid { + slot_id: "atf".to_string(), + price: Some(0.75), // decoded APS price — above floor + currency: "USD".to_string(), + creative: Some("
APS Ad
".to_string()), + adomain: None, + bidder: "aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + metadata: HashMap::new(), + }, + ); + + let filtered = orchestrator.apply_floor_prices(winning_bids, &floor_prices); + + assert_eq!( + filtered.len(), + 1, + "Decoded APS bid at or above floor should be kept" + ); + assert_eq!( + filtered.get("atf").expect("atf should be present").price, + Some(0.75), + "Price should be preserved" + ); + } } From f09eb34ffac5b3230a7a1c4af5804890e9b4954b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 15 May 2026 16:24:28 +0530 Subject: [PATCH 63/84] Document and test /auction API contract for non-Prebid.js callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand handle_auction doc: inline-params vs stored-request paths, config passthrough and allowed_context_keys, response headers - Document AdRequest, AdUnit, BidConfig with the stored-request contract: absent/empty bids → empty bidders map → PBS stored-request fallback - Add tests for convert_tsjs_to_auction_request: - No bids → empty bidders map (stored-request path) - Inline bids → bidders map populated - Allowed config key passes through; disallowed key dropped - Invalid 3-element banner size returns error Closes #699 --- .../src/auction/endpoints.rs | 41 +++- .../src/auction/formats.rs | 195 +++++++++++++++++- 2 files changed, 230 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 0430f08b..5a9ac6f1 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -16,11 +16,44 @@ use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_reques use super::types::AuctionContext; use super::AuctionOrchestrator; -/// Handle auction request from /auction endpoint. +/// Handle auction request from `POST /auction`. /// -/// This is the main entry point for running header bidding auctions. -/// It orchestrates bids from multiple providers (Prebid, APS, GAM, etc.) and returns -/// the winning bids in `OpenRTB` format with creative HTML inline in the `adm` field. +/// Accepts a JSON body matching [`AdRequest`][`super::formats::AdRequest`]. +/// The minimum valid request is: +/// +/// ```json +/// { +/// "adUnits": [{ +/// "code": "atf_sidebar_ad", +/// "mediaTypes": { "banner": { "sizes": [[300, 250]] } } +/// }] +/// } +/// ``` +/// +/// ## Bidder params: inline vs. stored-request +/// +/// Each ad unit's `bids` array is **optional**. When absent or empty the PBS +/// integration falls back to a stored-request keyed by the unit's `code` +/// field (`imp.ext.prebid.storedrequest = { id: "" }`). A PBS stored +/// request must therefore exist for every slot code that omits inline params. +/// +/// When `bids` is supplied, each entry's `bidder`/`params` pair is forwarded +/// directly as `imp.ext.prebid.bidder.`. +/// +/// ## Context passthrough (`config`) +/// +/// The optional `config` object is filtered through +/// [`auction.allowed_context_keys`][`crate::settings::AuctionConfig::allowed_context_keys`]. +/// Only keys listed there reach the auction providers (e.g. `"permutive_segments"`). +/// All other keys are silently dropped. Values must be either strings or arrays of +/// strings. +/// +/// ## Response +/// +/// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's +/// `adm` field after sanitisation and first-party URL rewriting. Response +/// headers include `X-TS-EC` (the caller's Edge Cookie ID) and +/// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal). /// /// # Errors /// diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 5237921a..53c6474a 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -28,7 +28,11 @@ use super::types::{ PublisherInfo, SiteInfo, UserInfo, }; -/// Request body format for auction endpoints (tsjs/Prebid.js format). +/// Request body for `POST /auction` (tsjs / Prebid.js wire format). +/// +/// `adUnits` lists the placements to bid on. `config` carries optional +/// context values (e.g. audience segments) filtered through +/// [`auction.allowed_context_keys`][`crate::settings::AuctionConfig::allowed_context_keys`]. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AdRequest { @@ -36,6 +40,15 @@ pub struct AdRequest { pub config: Option, } +/// A single ad placement in an [`AdRequest`]. +/// +/// `code` identifies the slot (e.g. `"atf_sidebar_ad"`) and becomes the +/// impression ID in the outgoing `OpenRTB` request. +/// +/// `bids` is optional. When absent or empty the PBS provider falls back to +/// a stored-request keyed by `code` (`imp.ext.prebid.storedrequest.id`). +/// When present, each entry's params are forwarded inline to PBS as +/// `imp.ext.prebid.bidder.`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AdUnit { @@ -44,7 +57,11 @@ pub struct AdUnit { pub bids: Option>, } -/// Bidder configuration from the request. +/// Inline bidder params for one SSP within an [`AdUnit`]. +/// +/// `params` is passed verbatim to the corresponding PBS bidder adapter. +/// When the `bids` array is absent, the slot falls back to PBS stored +/// requests — see [`AdUnit`] for details. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BidConfig { @@ -318,3 +335,177 @@ pub fn convert_to_openrtb_response( .with_header(HEADER_X_TS_EC_FRESH, &auction_request.user.fresh_id) .with_body(body_bytes)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::ConsentContext; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::crate_test_settings_str; + use fastly::http::Method; + use fastly::Request; + + fn make_settings() -> Settings { + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") + } + + fn make_req() -> Request { + Request::new(Method::POST, "https://test-publisher.com/auction") + } + + fn call_convert(body: &AdRequest) -> AuctionRequest { + let settings = make_settings(); + let services = noop_services(); + let req = make_req(); + convert_tsjs_to_auction_request( + body, + &settings, + &services, + &req, + ConsentContext::default(), + "test-ec-id", + None, + ) + .expect("should convert without error") + } + + #[test] + fn no_bids_produces_empty_bidders_map() { + // An ad unit with no `bids` array must produce an empty bidders map. + // An empty bidders map triggers the PBS stored-request fallback: + // the PBS provider sets imp.ext.prebid.storedrequest = { id: "" }. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "atf_sidebar_ad".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250]], + }), + }), + bids: None, + }], + config: None, + }; + + let auction_request = call_convert(&body); + + assert_eq!(auction_request.slots.len(), 1, "should have one slot"); + let slot = &auction_request.slots[0]; + assert_eq!(slot.id, "atf_sidebar_ad", "slot id should match unit code"); + assert!( + slot.bidders.is_empty(), + "absent bids array should yield empty bidders map (PBS stored-request path)" + ); + } + + #[test] + fn inline_bids_populate_bidders_map() { + // When bids are supplied, each bidder+params pair should appear in the + // slot's bidders map so PBS receives inline params. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "homepage_header_ad".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![970, 90]], + }), + }), + bids: Some(vec![BidConfig { + bidder: "kargo".to_string(), + params: serde_json::json!({ "placementId": "client_123" }), + }]), + }], + config: None, + }; + + let auction_request = call_convert(&body); + + let slot = &auction_request.slots[0]; + assert!( + slot.bidders.contains_key("kargo"), + "kargo bidder should be present in slot bidders map" + ); + assert_eq!( + slot.bidders["kargo"]["placementId"], "client_123", + "bidder params should be forwarded verbatim" + ); + } + + #[test] + fn config_allowed_key_passes_through() { + // Keys in auction.allowed_context_keys must reach the auction context. + // The test settings do not set allowed_context_keys so the default + // (empty) applies — verify a key is NOT present rather than IS. + // To test the allow-list, inject a key via a custom settings string. + let settings_str = format!( + "{}\n[auction]\nallowed_context_keys = [\"permutive_segments\"]\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&settings_str).expect("should parse"); + let services = noop_services(); + let req = make_req(); + + let body = AdRequest { + ad_units: vec![], + config: Some(serde_json::json!({ + "permutive_segments": ["seg1", "seg2"], + "disallowed_key": "should be dropped", + })), + }; + + let auction_request = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + "test-ec-id", + None, + ) + .expect("should convert"); + + assert!( + auction_request.context.contains_key("permutive_segments"), + "allowed key should be in auction context" + ); + assert!( + !auction_request.context.contains_key("disallowed_key"), + "unlisted key should be dropped" + ); + } + + #[test] + fn invalid_banner_size_returns_error() { + // Banner sizes must be [width, height] pairs; a 3-element size is invalid. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "bad_slot".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250, 99]], // invalid — 3 elements + }), + }), + bids: None, + }], + config: None, + }; + + let settings = make_settings(); + let services = noop_services(); + let req = make_req(); + let result = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + "test-ec-id", + None, + ); + + assert!( + result.is_err(), + "3-element banner size should return an error" + ); + } +} From a03c70a8cbbf6065eacb34c5a1ee1ad53c556025 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 15 May 2026 17:07:35 +0530 Subject: [PATCH 64/84] Verify and document graceful degradation when no slots match URL - Add debug log at no-match gate in handle_publisher_request and handle_page_bids so operators can confirm the feature is inactive on non-article URLs without reading source code - Add test: empty slots file (kill-switch) returns slots:[] bids:{} - Add test: URL not matching any slot pattern returns slots:[] bids:{} Closes #700 --- crates/trusted-server-core/src/publisher.rs | 117 ++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 18fc62c8..a4773a62 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -905,6 +905,13 @@ pub async fn handle_publisher_request( let should_run_auction = is_get && !is_prefetch && !is_bot && !matched_slots.is_empty() && consent_allows_auction; + if matched_slots.is_empty() && settings.creative_opportunities.is_some() { + log::debug!( + "No creative opportunity slots matched path '{}' — skipping auction and injection", + request_path + ); + } + let auction_timeout_ms = settings .creative_opportunities .as_ref() @@ -1454,6 +1461,13 @@ pub async fn handle_page_bids( .as_ref() .is_some_and(|tcf| tcf.has_purpose_consent(1)); + if matched_slots.is_empty() { + log::debug!( + "No creative opportunity slots matched path '{}' — skipping auction", + path_param + ); + } + let winning_bids = if !matched_slots.is_empty() && consent_allows_auction { let auction_request = build_auction_request( &matched_slots, @@ -2673,4 +2687,107 @@ mod tests { ); } } + + mod page_bids_no_match_tests { + use super::super::*; + use crate::auction::AuctionOrchestrator; + use crate::creative_opportunities::{ + CreativeOpportunitiesFile, CreativeOpportunityFormat, CreativeOpportunitySlot, + }; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::crate_test_settings_str; + use fastly::http::Method; + use fastly::Request; + + fn settings_with_co() -> Settings { + let toml = format!( + "{}\n[creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") + } + + fn file_with_article_slot() -> CreativeOpportunitiesFile { + CreativeOpportunitiesFile { + slots: vec![CreativeOpportunitySlot { + id: "atf".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: crate::auction::types::MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + }], + } + } + + fn make_page_bids_request(path: &str) -> Request { + Request::new( + Method::GET, + format!("https://test-publisher.com/_ts/page-bids?path={path}"), + ) + } + + #[tokio::test] + async fn empty_slots_file_returns_empty_slots_and_bids() { + // Spec §8 kill-switch: creative-opportunities.toml with zero slots disables + // all server-side auction activity and injection. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let slots_file = CreativeOpportunitiesFile { slots: vec![] }; + let req = make_page_bids_request("/2024/01/my-article/"); + + let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + .await + .expect("should return ok response"); + + let body: serde_json::Value = + serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); + + assert_eq!( + body["slots"].as_array().expect("slots should be array").len(), + 0, + "empty slots file should produce zero injected slots" + ); + assert_eq!( + body["bids"].as_object().expect("bids should be object").len(), + 0, + "empty slots file should produce zero bids" + ); + } + + #[tokio::test] + async fn url_not_matching_any_pattern_returns_empty_response() { + // Slots exist but request path does not match — no auction, no injection. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let slots_file = file_with_article_slot(); // slot matches /20** only + let req = make_page_bids_request("/about"); // does not match + + let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + .await + .expect("should return ok response"); + + let body: serde_json::Value = + serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); + + assert_eq!( + body["slots"].as_array().expect("slots should be array").len(), + 0, + "non-matching URL should produce zero injected slots" + ); + assert_eq!( + body["bids"].as_object().expect("bids should be object").len(), + 0, + "non-matching URL should produce zero bids" + ); + } + } } From 421833399efc17692a869e7355d5f105e6b99944 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 15 May 2026 17:10:45 +0530 Subject: [PATCH 65/84] Format publisher.rs with cargo fmt --- crates/trusted-server-core/src/publisher.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a4773a62..235be817 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2751,12 +2751,18 @@ mod tests { serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); assert_eq!( - body["slots"].as_array().expect("slots should be array").len(), + body["slots"] + .as_array() + .expect("slots should be array") + .len(), 0, "empty slots file should produce zero injected slots" ); assert_eq!( - body["bids"].as_object().expect("bids should be object").len(), + body["bids"] + .as_object() + .expect("bids should be object") + .len(), 0, "empty slots file should produce zero bids" ); @@ -2779,12 +2785,18 @@ mod tests { serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); assert_eq!( - body["slots"].as_array().expect("slots should be array").len(), + body["slots"] + .as_array() + .expect("slots should be array") + .len(), 0, "non-matching URL should produce zero injected slots" ); assert_eq!( - body["bids"].as_object().expect("bids should be object").len(), + body["bids"] + .as_object() + .expect("bids should be object") + .len(), 0, "non-matching URL should produce zero bids" ); From 0762999f37b977ffe7d61705cac5bb266ce32140 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 15 May 2026 17:13:35 +0530 Subject: [PATCH 66/84] Document scroll/refresh handoff contract between TS and slim-Prebid - Clarify in handle_auction doc that /auction is for initial render and programmatic callers; scroll/refresh/SPA navigation is slim-Prebid's domain in Phase 1 - Note Phase 2 slot-template-aware refresh API as deferred future work - Add head_inserts doc clarifying __tsAdInit handles initial render only; slotRenderEnded fires win beacons but does not trigger refresh auctions Closes #702 --- .../trusted-server-core/src/auction/endpoints.rs | 14 ++++++++++++++ crates/trusted-server-core/src/integrations/gpt.rs | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 5a9ac6f1..5d5bb292 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -55,6 +55,20 @@ use super::AuctionOrchestrator; /// headers include `X-TS-EC` (the caller's Edge Cookie ID) and /// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal). /// +/// ## Scroll, refresh, and SPA navigation +/// +/// This endpoint is intended for **initial page render** and **programmatic +/// callers** (e.g. slim-Prebid, native apps, server-to-server integrations). +/// It is **not** the intended path for scroll or GPT refresh events. +/// +/// In Phase 1, slim-Prebid owns scroll and refresh: it runs post-`window.load`, +/// listens for GPT refresh events, and runs client-side auctions independently +/// of this endpoint. SPAs that use pushState routing do not trigger TS page-level +/// auctions — slim-Prebid handles those cases too. +/// +/// A slot-template-aware refresh API (`POST /auction/refresh`) is deferred to a +/// future phase and not designed here. +/// /// # Errors /// /// Returns an error if: diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 690ba048..85ea800e 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -437,6 +437,19 @@ impl IntegrationHeadInjector for GptIntegration { GPT_INTEGRATION_ID } + /// Injects the `__tsAdInit` bootstrap script into ``. + /// + /// ## Scroll / refresh handoff contract (Phase 1) + /// + /// `__tsAdInit` handles **initial render only**: it wires server-side bid + /// targeting into GPT slots and fires win beacons (`nurl`/`burl`) via + /// `slotRenderEnded`. It does **not** trigger refresh auctions or handle + /// GPT slot refresh events. + /// + /// Post-`window.load`, slim-Prebid takes over: it listens for GPT refresh + /// events, runs client-side auctions, and sets targeting for subsequent + /// impressions. SPA pushState navigation is also slim-Prebid's domain. + /// The `POST /auction` endpoint is not involved in scroll or refresh flows. fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { vec![ ""# @@ -606,7 +624,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), ad_slots_script: None, - ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), } } @@ -1263,7 +1281,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), - ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), }; let mut processor = create_html_processor(config); let output = processor @@ -1287,7 +1305,7 @@ mod tests { 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 state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1316,7 +1334,7 @@ mod tests { 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 state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1342,7 +1360,7 @@ mod tests { fn injects_empty_ts_bids_when_slots_matched_but_auction_returned_nothing() { // Slots matched (ad_slots_script is Some) but auction task never wrote a result // (state is None) — e.g. auction timed out with zero bids. Fallback to {}. - let state = std::sync::Arc::new(std::sync::RwLock::new(None)); + let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1367,7 +1385,7 @@ mod tests { // No slots matched this URL — ad_slots_script is None. __ts_bids must be // omitted entirely so the publisher's existing client-side GPT flow is // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). - let state = std::sync::Arc::new(std::sync::RwLock::new(None)); + let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 850798e4..71ef2bbe 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -456,10 +456,16 @@ impl ApsAuctionProvider { aps_response.contextual.slots.len() ); - let slot_map = self - .slot_id_map - .lock() - .expect("should lock APS slot id map"); + // Take the map by value so it does not linger on the provider + // across requests if the Fastly Compute runtime ever reuses Wasm + // instances. Today each request gets its own instance so this is + // belt-and-suspenders; tomorrow it may not be. + let slot_map = std::mem::take( + &mut *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(mut bid) => { diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 85ea800e..cb099402 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -455,44 +455,21 @@ impl IntegrationHeadInjector for GptIntegration { "" .to_string(), - concat!( - "" - ).to_string(), + format!("", GPT_BOOTSTRAP_JS), ] } } +/// Inline `window.__tsAdInit` bootstrap injected at `` so the bids +/// script at `` can call it before the TSJS bundle has loaded. +/// +/// The bundle's idempotent implementation in +/// `crates/js/lib/src/integrations/gpt/index.ts` later overwrites this stub. +/// Both implementations guard the one-time-per-page setup with +/// `window.__tsServicesEnabled` so neither double-enables services if the +/// publisher's own init code also calls `googletag.enableServices()`. +const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); + // Default value functions fn default_enabled() -> bool { @@ -1120,6 +1097,32 @@ mod tests { ); } + #[test] + fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() { + 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 combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("__tsServicesEnabled"), + "should guard enableServices/enableSingleRequest with the __tsServicesEnabled flag" + ); + assert!( + combined.contains("window.__tsAdInit"), + "should install __tsAdInit on window" + ); + assert!( + !combined.contains("googletag.pubads().refresh()"), + "should never call unbounded refresh() — only refresh(newSlots)" + ); + } + #[test] fn head_injector_integration_id() { let integration = GptIntegration::new(test_config()); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js new file mode 100644 index 00000000..a3d28a28 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -0,0 +1,78 @@ +// Edge-injected GPT auction bootstrap. +// +// This is the minimal `window.__tsAdInit` that runs on first page load +// before the TSJS bundle has had a chance to install its richer +// idempotent implementation. The bundle in +// crates/js/lib/src/integrations/gpt/index.ts overwrites `__tsAdInit` +// once it loads. +// +// Contract with the bundle: +// - Both implementations must set `window.__tsServicesEnabled = true` +// after calling `enableSingleRequest()`/`enableServices()` so a +// subsequent call from any source (the bundle's `__tsAdInit`, the +// publisher's own GPT init code) becomes a no-op. +// - `refresh()` is called only for the slots defined in this pass, +// never the global slot list, so we never accidentally refresh +// publisher-managed slots that we don't own. +// +// Only installed if `window.__tsAdInit` isn't already defined — that +// way the bundle (or anything else) can preempt this fallback by +// installing first. +(function () { + if (typeof window === "undefined" || window.__tsAdInit) { + return; + } + window.__tsAdInit = function () { + var slots = window.__ts_ad_slots || []; + var bids = window.__ts_bids || {}; + var divToSlotId = {}; + googletag.cmd.push(function () { + var newSlots = []; + slots.forEach(function (slot) { + var s = googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + slot.div_id, + ); + if (!s) return; + s.addService(googletag.pubads()); + Object.entries(slot.targeting || {}).forEach(function (e) { + s.setTargeting(e[0], e[1]); + }); + var b = bids[slot.id] || {}; + ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]); + }); + s.setTargeting("ts_initial", "1"); + divToSlotId[slot.div_id] = slot.id; + newSlots.push(s); + }); + // Guard the one-time-per-page setup so a follow-up call (e.g. + // publisher's own init code or the bundle's `__tsAdInit` after + // it overwrites this stub) doesn't double-enable services. + if (!window.__tsServicesEnabled) { + googletag.pubads().enableSingleRequest(); + googletag.enableServices(); + window.__tsServicesEnabled = true; + googletag + .pubads() + .addEventListener("slotRenderEnded", function (ev) { + var divId = ev.slot.getSlotElementId(); + var slotId = divToSlotId[divId] || divId; + var b = (window.__ts_bids || {})[slotId] || {}; + var ourBidWon = + !ev.isEmpty && + b.hb_adid && + ev.slot.getTargeting("hb_adid")[0] === b.hb_adid; + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl); + if (b.burl) navigator.sendBeacon(b.burl); + } + }); + } + if (newSlots.length > 0) { + googletag.pubads().refresh(newSlots); + } + }); + }; +})(); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d7711d61..b74b234c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -164,10 +164,6 @@ pub struct PrebidIntegrationConfig { /// - `both` — consent in both cookies and body (default) #[serde(default)] pub consent_forwarding: ConsentForwardingMode, - /// When true, suppresses client-side nurl firing. - /// Use for PBS deployments that fire nurl internally. - #[serde(default)] - pub suppress_nurl: bool, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -1661,16 +1657,9 @@ mod tests { bid_param_overrides: HashMap::default(), bid_param_override_rules: Vec::new(), consent_forwarding: ConsentForwardingMode::Both, - suppress_nurl: false, } } - #[test] - fn prebid_config_suppress_nurl_defaults_to_false() { - let config = base_config(); - assert!(!config.suppress_nurl, "should not suppress nurl by default"); - } - fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), diff --git a/crates/trusted-server-core/src/price_bucket.rs b/crates/trusted-server-core/src/price_bucket.rs index b683020b..cfdca9eb 100644 --- a/crates/trusted-server-core/src/price_bucket.rs +++ b/crates/trusted-server-core/src/price_bucket.rs @@ -20,7 +20,10 @@ impl PriceGranularity { #[must_use] pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { - if cpm <= 0.0 { + // Reject NaN / Inf early so the `(x * 100.0).floor() as u64` cast below + // can never see a non-finite value (the cast's behaviour for NaN/Inf is + // implementation-defined in Rust and "saturate to 0" only by convention). + if !cpm.is_finite() || cpm <= 0.0 { return "0.00".to_string(); } match granularity { @@ -125,4 +128,31 @@ mod tests { price_bucket(2.53, PriceGranularity::Dense) ); } + + #[test] + fn non_finite_cpm_returns_zero_bucket() { + for granularity in [ + PriceGranularity::Dense, + PriceGranularity::Low, + PriceGranularity::Medium, + PriceGranularity::High, + PriceGranularity::Auto, + ] { + assert_eq!( + price_bucket(f64::NAN, granularity), + "0.00", + "NaN cpm should bucket to 0.00 for granularity {granularity:?}" + ); + assert_eq!( + price_bucket(f64::INFINITY, granularity), + "0.00", + "+Inf cpm should bucket to 0.00 for granularity {granularity:?}" + ); + assert_eq!( + price_bucket(f64::NEG_INFINITY, granularity), + "0.00", + "-Inf cpm should bucket to 0.00 for granularity {granularity:?}" + ); + } + } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 494f06eb..8908eaf0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12,7 +12,7 @@ //! content-rewriting concern. use std::io::Write; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex}; use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; @@ -39,6 +39,11 @@ use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, S use crate::streaming_replacer::create_url_replacer; const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"]; +/// Read buffer size for streaming body processing and brotli internal buffers. +/// Both the `Decompressor` and `CompressorWriter` use this value so all +/// brotli I/O layers operate on consistently-sized chunks. +const STREAM_CHUNK_SIZE: usize = 8192; + fn restrict_accept_encoding(req: &mut Request) { // If the client sent no Accept-Encoding, leave the request unchanged so the // origin responds without compression. Adding encodings here would cause the @@ -194,7 +199,7 @@ struct ProcessResponseParams<'a> { content_type: &'a str, integration_registry: &'a IntegrationRegistry, ad_slots_script: Option<&'a str>, - ad_bids_state: &'a Arc>>, + ad_bids_state: &'a Arc>>, } /// Process response body through the streaming pipeline. @@ -262,26 +267,32 @@ fn process_response_streaming( Ok(()) } -/// Create a unified HTML stream processor +/// Create a unified HTML stream processor. +/// +/// Builds the config via [`HtmlProcessorConfig::from_settings`] and then +/// layers the auction-hold streaming fields on top via +/// [`HtmlProcessorConfig::with_ad_state`], so the canonical builder stays the +/// single source of truth: a future field added to `from_settings` is +/// inherited here automatically. 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>>, + ad_bids_state: Arc>>, ) -> Result> { use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - 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, - }; + let config = HtmlProcessorConfig::from_settings( + settings, + integration_registry, + origin_host, + request_host, + request_scheme, + ) + .with_ad_state(ad_slots_script, ad_bids_state); Ok(create_html_processor(config)) } @@ -412,7 +423,7 @@ pub struct OwnedProcessResponseParams { pub(crate) request_scheme: String, pub(crate) content_type: String, pub(crate) ad_slots_script: Option, - pub(crate) ad_bids_state: Arc>>, + pub(crate) ad_bids_state: Arc>>, /// In-flight SSP bids dispatched before `pending_origin.wait()`. /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. @@ -493,7 +504,7 @@ pub async fn stream_publisher_body_async( if !is_html { // Non-HTML: collect auction first, then stream. There is no // to hold, so delaying the entire body until collection is acceptable. - let placeholder = Request::get("https://placeholder.invalid/"); + let placeholder = Request::get(crate::auction::types::MEDIATOR_PLACEHOLDER_URL); let result = orchestrator .collect_dispatched_auction( dispatched, @@ -540,16 +551,25 @@ pub async fn stream_publisher_body_async( .await } -/// Build a minimal [`AuctionContext`] for the mediator call in collection. +/// Build a minimal [`AuctionContext`] for the collect phase. /// -/// The `request` field is a short-lived placeholder (providers use it only for -/// header extraction; the placeholder is functionally equivalent to the original -/// since `req` was already consumed by `send_async` before dispatch). +/// See [`AuctionContext::request`]: the orchestrator's collect path runs +/// after `send_async` has already consumed the real client request, so this +/// context carries a synthetic placeholder. The orchestrator itself +/// instantiates a fresh placeholder when it actually invokes a mediator — +/// this argument is plumbing for the (presently unused) case where the +/// orchestrator needs the caller's request shape. fn make_collect_context<'a>( settings: &'a Settings, services: &'a RuntimeServices, placeholder: &'a Request, ) -> AuctionContext<'a> { + debug_assert_eq!( + placeholder.get_url_str(), + crate::auction::types::MEDIATOR_PLACEHOLDER_URL, + "make_collect_context must be given the canonical placeholder; \ + callers must not forward a real client request through the collect path" + ); AuctionContext { settings, request: placeholder, @@ -560,27 +580,87 @@ fn make_collect_context<'a>( } } +/// Well-known crawler User-Agent fragments. Best-effort: an attacker can +/// trivially spoof their UA, so this is for opt-out signalling to honest +/// crawlers (preventing SSP auctions burning partner quota on their behalf), +/// not security. +pub(crate) const BOT_USER_AGENT_FRAGMENTS: &[&str] = + &["Googlebot", "Bingbot", "AhrefsBot", "SemrushBot", "DotBot"]; + +/// Returns true when the request's User-Agent matches any well-known crawler +/// fragment in [`BOT_USER_AGENT_FRAGMENTS`]. +pub(crate) fn is_bot_user_agent(req: &Request) -> bool { + let ua = req.get_header_str("user-agent").unwrap_or(""); + BOT_USER_AGENT_FRAGMENTS + .iter() + .any(|frag| ua.contains(frag)) +} + +/// Returns true when the request advertises itself as a prefetch via either +/// the standard `Sec-Purpose` or the legacy `Purpose` header. +pub(crate) fn is_prefetch_request(req: &Request) -> bool { + 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")) +} + /// Write winning bids from an auction result into the shared `ad_bids_state` lock. pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, - ad_bids_state: &Arc>>, + ad_bids_state: &Arc>>, ) { - log::info!( + log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); let bid_map = build_bid_map(winning_bids, price_granularity); let bids_script = build_bids_script(&bid_map); - *ad_bids_state.write().expect("should write bid state") = Some(bids_script); + *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); +} + +/// Prepend an HTML comment summarising the auction result onto the shared +/// `ad_bids_state` so it lands directly before the injected 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 json = serde_json::to_string(bid_map) + .expect("serde_json::to_string of Map should be infallible"); let escaped = html_escape_for_script(&json); format!( "", @@ -1338,7 +1385,8 @@ pub(crate) fn build_ad_slots_script( }) }) .collect(); - let json = serde_json::to_string(&slots).unwrap_or_else(|_| "[]".to_string()); + let json = serde_json::to_string(&slots) + .expect("serde_json::to_string of Vec should be infallible"); let escaped = html_escape_for_script(&json); format!( "", @@ -1473,58 +1521,74 @@ pub async fn handle_page_bids( .as_ref() .is_some_and(|tcf| tcf.has_purpose_consent(1)); + // Same bot / prefetch guards the publisher path uses — without them this + // endpoint would fire real SSP auctions on Sec-Purpose=prefetch warm-up + // navigations and known crawler UA scans, burning partner request quota. + let is_prefetch = is_prefetch_request(&req); + let is_bot = is_bot_user_agent(&req); + if matched_slots.is_empty() { log::debug!( "No creative opportunity slots matched path '{}' — skipping auction", path_param ); + } else if is_bot || is_prefetch { + log::debug!( + "page-bids: skipping auction for path '{}' (is_bot={}, is_prefetch={})", + path_param, + is_bot, + is_prefetch + ); } - let winning_bids = if !matched_slots.is_empty() && consent_allows_auction { - let mut auction_request = build_auction_request( - &matched_slots, - &ec_id, - &consent_context, - &request_info, - &path_param, - co_config, - req.get_header_str("user-agent"), - ); - auction_request.user.eids = parse_ts_eids_cookie(cookie_jar.as_ref()); - let client_ip = services.client_info.client_ip.map(|ip| ip.to_string()); - if client_ip.is_some() || geo.is_some() { - let device = auction_request.device.get_or_insert(DeviceInfo { - user_agent: None, - ip: None, - geo: None, - }); - device.ip = client_ip; - device.geo = geo.clone(); - } - let timeout_ms = co_config - .auction_timeout_ms - .unwrap_or(settings.auction.timeout_ms); - let auction_context = AuctionContext { - settings, - request: &req, - client_info: services.client_info(), - timeout_ms, - provider_responses: None, - services, - }; - match orchestrator - .run_auction(&auction_request, &auction_context, services) - .await - { - Ok(result) => result.winning_bids, - Err(e) => { - log::warn!("page-bids auction failed: {e:?}"); - std::collections::HashMap::new() + let winning_bids = + if !matched_slots.is_empty() && consent_allows_auction && !is_bot && !is_prefetch { + let slots_ctx = MatchedSlotsContext { + matched_slots: &matched_slots, + request_path: &path_param, + }; + let mut auction_request = build_auction_request( + &slots_ctx, + &ec_id, + &consent_context, + &request_info, + req.get_header_str("user-agent"), + ); + auction_request.user.eids = parse_ts_eids_cookie(cookie_jar.as_ref()); + let client_ip = services.client_info.client_ip.map(|ip| ip.to_string()); + if client_ip.is_some() || geo.is_some() { + let device = auction_request.device.get_or_insert(DeviceInfo { + user_agent: None, + ip: None, + geo: None, + }); + device.ip = client_ip; + device.geo = geo.clone(); } - } - } else { - std::collections::HashMap::new() - }; + let timeout_ms = co_config + .auction_timeout_ms + .unwrap_or(settings.auction.timeout_ms); + let auction_context = AuctionContext { + settings, + request: &req, + client_info: services.client_info(), + timeout_ms, + provider_responses: None, + services, + }; + match orchestrator + .run_auction(&auction_request, &auction_context, services) + .await + { + Ok(result) => result.winning_bids, + Err(e) => { + log::warn!("page-bids auction failed: {e:?}"); + std::collections::HashMap::new() + } + } + } else { + std::collections::HashMap::new() + }; let bid_map = build_bid_map(&winning_bids, co_config.price_granularity); @@ -2223,7 +2287,7 @@ mod tests { request_scheme: "https".to_string(), content_type: "text/css".to_string(), ad_slots_script: None, - ad_bids_state: Arc::new(RwLock::new(None)), + ad_bids_state: Arc::new(Mutex::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -2268,7 +2332,7 @@ mod tests { 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)), + ad_bids_state: Arc::new(Mutex::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -2304,7 +2368,7 @@ mod tests { request_scheme: "https".to_string(), content_type: "text/html".to_string(), ad_slots_script: None, - ad_bids_state: Arc::new(RwLock::new(None)), + ad_bids_state: Arc::new(Mutex::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -2407,7 +2471,7 @@ mod tests { 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)), + ad_bids_state: Arc::new(Mutex::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -2461,7 +2525,7 @@ mod tests { request_scheme: "https".to_string(), content_type: "text/html".to_string(), ad_slots_script: None, - ad_bids_state: Arc::new(RwLock::new(None)), + ad_bids_state: Arc::new(Mutex::new(None)), dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -2527,6 +2591,7 @@ mod tests { .into_iter() .collect(), providers: Default::default(), + compiled_patterns: Vec::new(), } } @@ -2745,6 +2810,7 @@ mod tests { floor_price: Some(0.50), targeting: Default::default(), providers: Default::default(), + compiled_patterns: Vec::new(), }], } } @@ -2791,6 +2857,79 @@ mod tests { ); } + #[tokio::test] + async fn bot_user_agent_returns_slots_but_no_bids() { + // Crawlers should get slot definitions (so HTML structure is unchanged) + // but the server must not burn SSP request quota running a real auction + // for them. Same gate the publisher path applies. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let slots_file = file_with_article_slot(); + let mut req = make_page_bids_request("/2024/01/my-article/"); + req.set_header("user-agent", "Mozilla/5.0 (compatible; Googlebot/2.1)"); + + let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + .await + .expect("should return ok response"); + + let body: serde_json::Value = + serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 1, + "bot request should still get slot definitions" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "bot request must not run an auction (no SSP cost burned for crawlers)" + ); + } + + #[tokio::test] + async fn prefetch_request_returns_slots_but_no_bids() { + // Navigations triggered by Sec-Purpose=prefetch should not fire real + // SSP auctions — the user has not yet visited the page. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let slots_file = file_with_article_slot(); + let mut req = make_page_bids_request("/2024/01/my-article/"); + req.set_header("sec-purpose", "prefetch"); + + let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + .await + .expect("should return ok response"); + + let body: serde_json::Value = + serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 1, + "prefetch request should still get slot definitions" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "prefetch request must not run an auction" + ); + } + #[tokio::test] async fn url_not_matching_any_pattern_returns_empty_response() { // Slots exist but request path does not match — no auction, no injection. From 93c1678d2ce13be9a3cad7e30954ed7a4cba0394 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 23 May 2026 23:02:54 +0530 Subject: [PATCH 70/84] Address PR review findings from #680 - Fix gpt_bootstrap.js APS beacon miss: listener now uses hb_bidder fallback when hb_adid is absent, matching the bundle's slotRenderEnded logic - Fix stale divToSlotId in inline listener: read from window.__tsDivToSlotId dynamically instead of local closure so SPA navigation updates are seen; early-return for slots not managed by Trusted Server - Populate window.__tsPrevGptSlots and window.__tsDivToSlotId from inline bootstrap so bundle's destroySlots and SPA nav path have correct state - Call installSlimPrebidLoader() in module init so the slim-Prebid lazy loader activates when __tsjs_slim_prebid_url is set; add three Vitest cases - Update /auction doc comment to distinguish /__ts/page-bids (SPA navigation) from /auction (initial render) and slim-Prebid (scroll/refresh) --- crates/js/lib/src/integrations/gpt/index.ts | 1 + .../lib/test/integrations/gpt/index.test.ts | 51 +++++++++++++++++++ .../src/auction/endpoints.rs | 12 +++-- .../src/integrations/gpt_bootstrap.js | 17 +++++-- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index fee79c1b..611b0aea 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -396,4 +396,5 @@ if (typeof window !== 'undefined') { installTsAdInit(); installSpaAuctionHook(); + installSlimPrebidLoader(); } diff --git a/crates/js/lib/test/integrations/gpt/index.test.ts b/crates/js/lib/test/integrations/gpt/index.test.ts index 57c4015d..839b121d 100644 --- a/crates/js/lib/test/integrations/gpt/index.test.ts +++ b/crates/js/lib/test/integrations/gpt/index.test.ts @@ -165,6 +165,57 @@ describe('GPT shim – patchCommandQueue', () => { }); }); +describe('GPT – installSlimPrebidLoader', () => { + type SlimWindow = Window & { __tsjs_slim_prebid_url?: string }; + + afterEach(() => { + delete (window as SlimWindow).__tsjs_slim_prebid_url; + }); + + it('is a no-op when __tsjs_slim_prebid_url is not set', async () => { + const { installSlimPrebidLoader } = await import('../../../src/integrations/gpt/index'); + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + installSlimPrebidLoader(); + expect(addEventListenerSpy).not.toHaveBeenCalledWith('load', expect.any(Function)); + addEventListenerSpy.mockRestore(); + }); + + it('appends a deferred script tag when __tsjs_slim_prebid_url is set and load fires', async () => { + (window as SlimWindow).__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid.js'; + const { installSlimPrebidLoader } = await import('../../../src/integrations/gpt/index'); + + installSlimPrebidLoader(); + + // Simulate the window load event. + window.dispatchEvent(new Event('load')); + + const scripts = Array.from(document.querySelectorAll('script[defer]')); + const injected = scripts.find( + (s) => (s as HTMLScriptElement).src === 'https://cdn.example.com/slim-prebid.js' + ); + expect(injected).toBeDefined(); + + // Clean up + injected?.parentNode?.removeChild(injected); + }); + + it('module init calls installSlimPrebidLoader — script injected when URL is preset', async () => { + vi.resetModules(); + (window as SlimWindow).__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid-init.js'; + + await import('../../../src/integrations/gpt/index'); + window.dispatchEvent(new Event('load')); + + const scripts = Array.from(document.querySelectorAll('script[defer]')); + const injected = scripts.find( + (s) => (s as HTMLScriptElement).src === 'https://cdn.example.com/slim-prebid-init.js' + ); + expect(injected).toBeDefined(); + + injected?.parentNode?.removeChild(injected); + }); +}); + describe('GPT shim – runtime gating', () => { type GatedWindow = Window & { __tsjs_gpt_enabled?: boolean; diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 5b4e7b25..22fc11e8 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -61,10 +61,14 @@ use super::AuctionOrchestrator; /// callers** (e.g. slim-Prebid, native apps, server-to-server integrations). /// It is **not** the intended path for scroll or GPT refresh events. /// -/// In Phase 1, slim-Prebid owns scroll and refresh: it runs post-`window.load`, -/// listens for GPT refresh events, and runs client-side auctions independently -/// of this endpoint. SPAs that use pushState routing do not trigger TS page-level -/// auctions — slim-Prebid handles those cases too. +/// **SPA navigation** is handled by `GET /__ts/page-bids`: the client-side SPA +/// hook (`installSpaAuctionHook`) intercepts `pushState`/`replaceState`/`popstate` +/// events and calls that endpoint to fetch fresh slots and bids for each new +/// route, then invokes `window.__tsAdInit()` with the updated data. +/// +/// **Scroll and GPT refresh** are owned by slim-Prebid in Phase 1: it runs +/// post-`window.load`, listens for GPT refresh events, and runs client-side +/// auctions independently of this endpoint. /// /// A slot-template-aware refresh API (`POST /auction/refresh`) is deferred to a /// future phase and not designed here. diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index a3d28a28..85109d72 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -47,6 +47,11 @@ divToSlotId[slot.div_id] = slot.id; newSlots.push(s); }); + // Expose slot metadata on window so later calls (SPA navigation, + // the bundle's __tsAdInit) can destroy stale slots and the render + // listener can resolve slot IDs after navigation updates these maps. + window.__tsPrevGptSlots = newSlots; + window.__tsDivToSlotId = divToSlotId; // Guard the one-time-per-page setup so a follow-up call (e.g. // publisher's own init code or the bundle's `__tsAdInit` after // it overwrites this stub) doesn't double-enable services. @@ -58,12 +63,18 @@ .pubads() .addEventListener("slotRenderEnded", function (ev) { var divId = ev.slot.getSlotElementId(); - var slotId = divToSlotId[divId] || divId; + // Read from window so SPA navigation updates are picked up; + // early-return for slots not managed by Trusted Server. + var slotId = (window.__tsDivToSlotId || {})[divId]; + if (!slotId) return; var b = (window.__ts_bids || {})[slotId] || {}; + // Prebid: verify the specific creative via hb_adid targeting. + // APS: no hb_adid — fire if any TS bidder is present and slot is non-empty. var ourBidWon = !ev.isEmpty && - b.hb_adid && - ev.slot.getTargeting("hb_adid")[0] === b.hb_adid; + (b.hb_adid + ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid + : !!b.hb_bidder); if (ourBidWon) { if (b.nurl) navigator.sendBeacon(b.nurl); if (b.burl) navigator.sendBeacon(b.burl); From 0346330905b3e5e8487ecb566baac6c2504d5214 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 26 May 2026 09:42:33 -0700 Subject: [PATCH 71/84] Formatting --- crates/trusted-server-core/src/integrations/sourcepoint.rs | 4 ++-- creative-opportunities.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index a911b5d5..adea7b44 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -1073,9 +1073,9 @@ mod tests { let integration = SourcepointIntegration::new(Arc::new(config(true))); let document_state = IntegrationDocumentState::default(); let ctx = IntegrationHtmlContext { - request_host: "ts.autoblog.com", + request_host: "ts.examnple.com", request_scheme: "https", - origin_host: "origin.autoblog.com", + origin_host: "origin.examnple.com", document_state: &document_state, }; diff --git a/creative-opportunities.toml b/creative-opportunities.toml index b6ed8900..da1ed23e 100644 --- a/creative-opportunities.toml +++ b/creative-opportunities.toml @@ -3,7 +3,7 @@ [[slot]] id = "atf_sidebar_ad" -gam_unit_path = "/88059007/autoblog/news" +gam_unit_path = "/a/b/news" div_id = "ad-atf_sidebar-0-_r_2_" page_patterns = ["/20**", "/news/**"] formats = [{ width = 300, height = 250 }] @@ -18,7 +18,7 @@ slot_id = "aps-slot-atf-sidebar" [[slot]] id = "homepage_header_ad" -gam_unit_path = "/88059007/autoblog/homepage" +gam_unit_path = "/a/b/homepage" div_id = "ad-header-0-_R_jpalubtak5lb_" page_patterns = ["/"] formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] @@ -33,7 +33,7 @@ slot_id = "aps-slot-homepage-header" [[slot]] id = "homepage_footer_ad" -gam_unit_path = "/88059007/autoblog/homepage" +gam_unit_path = "/a/b/homepage" div_id = "ad-fixed_bottom-0-_R_klubtak5lb_" page_patterns = ["/"] formats = [{ width = 728, height = 90 }] From ca98985b003eb70931e5f52d8ff734deac92b140 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 28 May 2026 15:36:29 +0530 Subject: [PATCH 72/84] Address pass-4 review findings (#680) - Fix examnple.com typo in sourcepoint.rs test fixture - Guard SPA navigation race: check inflight controller identity after await res.json() before writing __ts_ad_slots/__ts_bids - Extract build_empty_bids_script() helper; html_processor.rs now calls it instead of duplicating the inline literal - Add invariant comment to unreachable None branch of prepend_auction_debug_comment - Cap parse_ts_eids_cookie to 32 eids / 32 uids per eid; log and return None when exceeded - Add #[serde(deny_unknown_fields)] to openrtb::Eid and Uid - Add #[serde(deny_unknown_fields)] to CreativeOpportunitiesFile and CreativeOpportunitySlot - Log debug message when adserver_mock crid does not match -creative convention - Skip zero-dimension bids in adserver_mock with debug log - Fail closed in APS parse_aps_slot on malformed size string instead of producing 0x0 bid --- crates/js/lib/src/integrations/gpt/index.ts | 1 + crates/trusted-server-core/src/cookies.rs | 8 +++++++- .../src/creative_opportunities.rs | 2 ++ .../trusted-server-core/src/html_processor.rs | 11 +++++----- .../src/integrations/adserver_mock.rs | 20 ++++++++++++++++--- .../src/integrations/aps.rs | 12 ++++++++++- .../src/integrations/sourcepoint.rs | 4 ++-- crates/trusted-server-core/src/openrtb.rs | 2 ++ crates/trusted-server-core/src/publisher.rs | 10 ++++++++++ 9 files changed, 57 insertions(+), 13 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 611b0aea..e1a1ee26 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -328,6 +328,7 @@ export function installSpaAuctionHook(): void { }); if (!res.ok) return; const data = (await res.json()) as PageBidsResponse; + if (inflight !== controller) return; win.__ts_ad_slots = data.slots; win.__ts_bids = data.bids; win.__tsAdInit?.(); diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 4f0e7f9c..91f92d83 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -141,7 +141,13 @@ pub(crate) fn parse_ts_eids_cookie(jar: Option<&CookieJar>) -> Option>(&decoded) { - Ok(eids) if !eids.is_empty() => Some(eids), + Ok(eids) if !eids.is_empty() => { + if eids.len() > 32 || eids.iter().any(|e| e.uids.len() > 32) { + log::debug!("ts-eids cookie: too many eids or uids, rejecting"); + return None; + } + Some(eids) + } Ok(_) => None, Err(e) => { log::debug!("ts-eids cookie: JSON parse failed: {e}"); diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index cbf79b11..95180041 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -42,6 +42,7 @@ pub struct CreativeOpportunitiesConfig { /// A single ad placement opportunity on the publisher's site. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CreativeOpportunitySlot { /// Unique identifier for the slot (e.g., `"atf"`, `"below-fold-sidebar"`). pub id: String, @@ -224,6 +225,7 @@ pub struct ApsSlotParams { /// TOML file structure for creative opportunity slot definitions. #[derive(Debug, Clone, Deserialize, Default)] +#[serde(deny_unknown_fields)] pub struct CreativeOpportunitiesFile { /// All slot definitions in the file (mapped from `[[slot]]` TOML arrays). #[serde(rename = "slot", default)] diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index c54c4689..6005e3cc 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -34,6 +34,7 @@ use crate::integrations::{ IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationRegistry, IntegrationScriptContext, ScriptRewriteAction, }; +use crate::publisher::build_empty_bids_script; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; @@ -328,21 +329,19 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso 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<'_>| { + let handler: EndTagHandler<'static> = + Box::new(move |end_tag: &mut EndTag<'_>| { if injected_bids.swap(true, Ordering::SeqCst) { return Ok(()); } let script_guard = state.lock().expect("should lock bid state"); let bids_script = match &*script_guard { Some(s) => s.clone(), - None => r#""# - .to_string(), + None => build_empty_bids_script(), }; end_tag.before(&bids_script, ContentType::Html); Ok(()) - }, - ); + }); handlers.push(handler); } Ok(()) diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 1d968484..beacef1d 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -251,17 +251,31 @@ impl AdServerMockProvider { // Recover bidder name from crid ("{bidder}-creative") to look up the // original SSP bid and restore nurl/burl/ad_id the mediator drops. let crid = bid["crid"].as_str().unwrap_or(""); - let bidder = crid.strip_suffix("-creative").unwrap_or(""); + let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| { + log::debug!( + "adserver_mock: crid '{crid}' does not match '-creative' — dropping nurl/burl/ad_id" + ); + "" + }); let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string()); let original = bid_index.get(&key); + let width = bid["w"].as_u64().unwrap_or(0) as u32; + let height = bid["h"].as_u64().unwrap_or(0) as u32; + if width == 0 || height == 0 { + log::debug!( + "adserver_mock: bid for slot '{slot_id}' has zero dimension ({width}×{height}), skipping" + ); + continue; + } + all_bids.push(Bid { slot_id, price: bid["price"].as_f64(), currency: "USD".to_string(), creative: bid["adm"].as_str().map(String::from), - width: bid["w"].as_u64().unwrap_or(0) as u32, - height: bid["h"].as_u64().unwrap_or(0) as u32, + width, + height, bidder: seat_name.to_string(), adomain: bid["adomain"].as_array().map(|arr| { arr.iter() diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 71ef2bbe..304f61a0 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -406,7 +406,17 @@ impl ApsAuctionProvider { } // Parse size from "WxH" format - let (width, height) = Self::parse_size(&slot.size).unwrap_or((0, 0)); + let (width, height) = match Self::parse_size(&slot.size) { + Some(dims) => dims, + None => { + log::debug!( + "APS: slot '{}' has malformed size '{}', skipping", + slot.slot_id, + slot.size + ); + return Err(()); + } + }; // Build metadata from targeting keys - includes encoded price for mediation let mut metadata = HashMap::new(); diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index adea7b44..b48075a6 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -1073,9 +1073,9 @@ mod tests { let integration = SourcepointIntegration::new(Arc::new(config(true))); let document_state = IntegrationDocumentState::default(); let ctx = IntegrationHtmlContext { - request_host: "ts.examnple.com", + request_host: "ts.example.com", request_scheme: "https", - origin_host: "origin.examnple.com", + origin_host: "origin.example.com", document_state: &document_state, }; diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 63d63435..aff58060 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -76,6 +76,7 @@ pub struct ConsentedProvidersSettings { /// An Extended User ID entry from an identity provider. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Eid { /// Identity provider domain (e.g. `"id5-sync.com"`). pub source: String, @@ -85,6 +86,7 @@ pub struct Eid { /// A single user identifier within an [`Eid`] entry. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Uid { /// The identifier value. pub id: String, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 8908eaf0..c6ffa776 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -651,6 +651,8 @@ pub(crate) fn prepend_auction_debug_comment( *script = format!("{debug_comment}\n{script}"); } None => { + // invariant: write_bids_to_state is always called before this and + // always sets Some(_); this branch is unreachable in production. *state = Some(debug_comment); } } @@ -1353,6 +1355,14 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map` tag used when no bids were returned. +/// +/// Shares the same shape as [`build_bids_script`] so any change to the script +/// format stays in one place. +pub(crate) fn build_empty_bids_script() -> String { + build_bids_script(&serde_json::Map::new()) +} + /// Build the `__ts_ad_slots` ``. + /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, /// Shared auction result — written by auction task before HTML processing begins. /// Handler reads this in `el.on_end_tag()` on the body element. - /// `None` means no auction ran; inject empty `__ts_bids = {}` as fallback. + /// `None` means no auction ran; inject empty `tsjs.bids = {}` as fallback. pub ad_bids_state: std::sync::Arc>>, } @@ -311,10 +311,10 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) } }), - // Inject __ts_bids before via end_tag_handlers — only when + // Inject tsjs.bids before via end_tag_handlers — only when // slots matched this URL. When no slots matched, skip injection entirely // so the publisher's existing client-side Prebid/GPT flow is unmodified - // (dual-mode rollout: calling __tsAdInit with empty slots would invoke + // (dual-mode rollout: calling tsjs.adInit with empty slots would invoke // enableSingleRequest/enableServices and conflict with the publisher's GPT init). // Guard with AtomicBool so the script is only injected once even if // the origin HTML contains multiple elements (e.g. template fragments). @@ -1278,7 +1278,8 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: Some( - r#""#.to_string(), + r#""# + .to_string(), ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), }; @@ -1291,8 +1292,12 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window.__ts_ad_slots"), - "should inject ad slots at head-open" + html.contains("window.tsjs=window.tsjs||{}"), + "should inject ad slots namespace at head-open" + ); + assert!( + html.contains(".adSlots=JSON.parse"), + "should inject adSlots at head-open" ); assert!( !html.contains("__ts_request_id"), @@ -1302,15 +1307,16 @@ mod tests { #[test] fn injects_ts_bids_before_body_close() { - let bids_script = - r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1319,27 +1325,32 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window.__ts_bids"), + html.contains("window.tsjs=window.tsjs||{}"), + "should inject _ts namespace for bids before " + ); + assert!( + html.contains(".bids=JSON.parse"), "should inject bids before " ); let bids_pos = html - .find("window.__ts_bids") - .expect("bids should be in output"); + .find("window.tsjs=window.tsjs||{}") + .expect("bids namespace should be in output"); let body_close_pos = html.find("").expect(" should be in output"); assert!(bids_pos < body_close_pos, "bids must appear before "); } #[test] fn injects_ts_bids_only_once_with_multiple_body_elements() { - let bids_script = - r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1349,9 +1360,9 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert_eq!( - html.matches("window.__ts_bids").count(), + html.matches(".bids=JSON.parse").count(), 1, - "should inject __ts_bids exactly once even with multiple elements" + "should inject tsjs.bids exactly once even with multiple elements" ); } @@ -1365,7 +1376,9 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1374,14 +1387,14 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("__ts_bids=JSON.parse(\"{}\")"), + html.contains(".bids=JSON.parse(\"{}\")"), "should inject empty bids fallback when auction produced nothing" ); } #[test] fn does_not_inject_ts_bids_when_no_slots_matched() { - // No slots matched this URL — ad_slots_script is None. __ts_bids must be + // No slots matched this URL — ad_slots_script is None. tsjs.bids must be // omitted entirely so the publisher's existing client-side GPT flow is // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). let state = std::sync::Arc::new(std::sync::Mutex::new(None)); @@ -1399,8 +1412,8 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - !html.contains("__ts_bids"), - "should NOT inject __ts_bids when no slots matched" + !html.contains(".bids=JSON.parse"), + "should NOT inject tsjs.bids when no slots matched" ); } } diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index beacef1d..483e4499 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -89,7 +89,7 @@ impl IntegrationConfig for AdServerMockConfig { // ============================================================================ /// Lookup index built from original SSP bids during `request_bids`, consumed -/// during `parse_response` to restore `nurl`/`burl`/`ad_id` that the mock +/// during `parse_response` to restore render/accounting fields that the mock /// mediator endpoint does not echo back. /// /// Keyed by `(provider_name, slot_id, bidder_name)`. @@ -98,7 +98,7 @@ type BidIndex = HashMap<(String, String, String), Bid>; /// Mock ad server mediator provider. pub struct AdServerMockProvider { config: AdServerMockConfig, - /// Bridges SSP bid metadata (`nurl`/`burl`/`ad_id`) from `request_bids` to `parse_response`. + /// Bridges SSP bid metadata from `request_bids` to `parse_response`. bid_index: Mutex>, } @@ -226,7 +226,7 @@ impl AdServerMockProvider { /// Mediation returns decoded prices for all bids (including APS bids that were encoded). /// /// `bid_index` is the SSP-bid lookup built in `request_bids`. The mock mediator - /// does not echo `nurl`/`burl`/`ad_id` back, so they are restored from the index + /// does not echo render/accounting fields back, so they are restored from the index /// using `(seat, impid, bidder)` where bidder is recovered from the echoed `crid` /// field (`"{bidder}-creative"` format set during request construction). fn parse_mediation_response( @@ -249,16 +249,18 @@ impl AdServerMockProvider { let slot_id = bid["impid"].as_str().unwrap_or("").to_string(); // Recover bidder name from crid ("{bidder}-creative") to look up the - // original SSP bid and restore nurl/burl/ad_id the mediator drops. + // original SSP bid and restore render/accounting fields the mediator drops. let crid = bid["crid"].as_str().unwrap_or(""); let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| { log::debug!( - "adserver_mock: crid '{crid}' does not match '-creative' — dropping nurl/burl/ad_id" + "adserver_mock: crid '{crid}' does not match '-creative'; render/accounting fields may be missing" ); "" }); let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string()); let original = bid_index.get(&key); + let restored_bidder = + original.map_or_else(|| seat_name.to_string(), |b| b.bidder.clone()); let width = bid["w"].as_u64().unwrap_or(0) as u32; let height = bid["h"].as_u64().unwrap_or(0) as u32; @@ -276,7 +278,7 @@ impl AdServerMockProvider { creative: bid["adm"].as_str().map(String::from), width, height, - bidder: seat_name.to_string(), + bidder: restored_bidder, adomain: bid["adomain"].as_array().map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) @@ -285,6 +287,9 @@ impl AdServerMockProvider { nurl: original.and_then(|b| b.nurl.clone()), burl: original.and_then(|b| b.burl.clone()), ad_id: original.and_then(|b| b.ad_id.clone()), + cache_id: original.and_then(|b| b.cache_id.clone()), + cache_host: original.and_then(|b| b.cache_host.clone()), + cache_path: original.and_then(|b| b.cache_path.clone()), metadata: HashMap::new(), }); } @@ -563,6 +568,9 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }], response_time_ms: 150, @@ -583,6 +591,9 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }], response_time_ms: 120, @@ -656,6 +667,98 @@ mod tests { assert_eq!(bid.height, 90); } + #[test] + fn parse_mediation_response_restores_original_bid_render_fields() { + let provider = AdServerMockProvider::new(AdServerMockConfig::default()); + let mediation_response = json!({ + "id": "test-auction-123", + "seatbid": [ + { + "seat": "prebid", + "bid": [ + { + "id": "mediated-bid-001", + "impid": "header-banner", + "price": 0.20, + "adm": "
Mediated Ad
", + "w": 728, + "h": 90, + "crid": "mocktioneer-creative", + "adomain": ["example.com"] + } + ] + } + ], + "cur": "USD" + }); + let mut bid_index = BidIndex::new(); + bid_index.insert( + ( + "prebid".to_string(), + "header-banner".to_string(), + "mocktioneer".to_string(), + ), + Bid { + slot_id: "header-banner".to_string(), + price: Some(0.20), + currency: "USD".to_string(), + creative: Some("
Original Ad
".to_string()), + adomain: Some(vec!["example.com".to_string()]), + bidder: "mocktioneer".to_string(), + width: 728, + height: 90, + nurl: Some("https://ssp.example/win".to_string()), + burl: Some("https://ssp.example/bill".to_string()), + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("cache-uuid".to_string()), + cache_host: Some("cache.example".to_string()), + cache_path: Some("/cache".to_string()), + metadata: HashMap::new(), + }, + ); + + let auction_response = + provider.parse_mediation_response(&mediation_response, 42, &bid_index); + + assert_eq!(auction_response.status, BidStatus::Success); + assert_eq!(auction_response.bids.len(), 1); + let bid = &auction_response.bids[0]; + assert_eq!( + bid.bidder, "mocktioneer", + "should preserve underlying bidder for hb_bidder targeting" + ); + assert_eq!( + bid.nurl.as_deref(), + Some("https://ssp.example/win"), + "should restore nurl" + ); + assert_eq!( + bid.burl.as_deref(), + Some("https://ssp.example/bill"), + "should restore burl" + ); + assert_eq!( + bid.ad_id.as_deref(), + Some("bid-impression-id"), + "should restore ad_id" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid"), + "should restore PBS cache UUID" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("cache.example"), + "should restore PBS cache host" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should restore PBS cache path" + ); + } + #[test] fn test_parse_empty_mediation_response() { let config = AdServerMockConfig::default(); @@ -727,6 +830,9 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: aps_metadata, }], response_time_ms: 100, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 304f61a0..d1c449bf 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -451,6 +451,9 @@ impl ApsAuctionProvider { nurl: None, // Real APS uses client-side event tracking burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata, }) } diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index cb099402..5f88f69c 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -81,6 +81,16 @@ pub struct GptConfig { /// Whether to rewrite GPT script URLs in publisher HTML. #[serde(default = "default_rewrite_script")] pub rewrite_script: bool, + + /// URL for the slim-Prebid bundle loaded post-window.load. + /// + /// When set, `installSlimPrebidLoader()` in the GPT bundle will load this + /// script after `window.load`, enabling scroll/refresh client-side auctions + /// and userID module warm-up. Set to the publisher's tsjs-prebid bundle URL. + /// + /// Override via env var: `TRUSTED_SERVER__INTEGRATIONS__GPT__SLIM_PREBID_URL` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slim_prebid_url: Option, } impl IntegrationConfig for GptConfig { @@ -437,11 +447,11 @@ impl IntegrationHeadInjector for GptIntegration { GPT_INTEGRATION_ID } - /// Injects the `__tsAdInit` bootstrap script into ``. + /// Injects the `tsjs.adInit` bootstrap script into ``. /// /// ## Scroll / refresh handoff contract (Phase 1) /// - /// `__tsAdInit` handles **initial render only**: it wires server-side bid + /// `tsjs.adInit` handles **initial render only**: it wires server-side bid /// targeting into GPT slots and fires win beacons (`nurl`/`burl`) via /// `slotRenderEnded`. It does **not** trigger refresh auctions or handle /// GPT slot refresh events. @@ -451,22 +461,31 @@ impl IntegrationHeadInjector for GptIntegration { /// impressions. SPA pushState navigation is also slim-Prebid's domain. /// The `POST /auction` endpoint is not involved in scroll or refresh flows. fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - vec![ + let mut scripts = vec![ "" .to_string(), format!("", GPT_BOOTSTRAP_JS), - ] + ]; + + if let Some(ref url) = self.config.slim_prebid_url { + scripts.push(format!( + "", + serde_json::to_string(url).expect("should serialize string") + )); + } + + scripts } } -/// Inline `window.__tsAdInit` bootstrap injected at `` so the bids +/// Inline `window.tsjs.adInit` bootstrap injected at `` so the bids /// script at `` can call it before the TSJS bundle has loaded. /// /// The bundle's idempotent implementation in /// `crates/js/lib/src/integrations/gpt/index.ts` later overwrites this stub. /// Both implementations guard the one-time-per-page setup with -/// `window.__tsServicesEnabled` so neither double-enables services if the +/// `window.tsjs.servicesEnabled` so neither double-enables services if the /// publisher's own init code also calls `googletag.enableServices()`. const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); @@ -502,6 +521,7 @@ mod tests { script_url: default_script_url(), cache_ttl_seconds: 3600, rewrite_script: true, + slim_prebid_url: None, } } @@ -1062,10 +1082,10 @@ mod tests { }; let inserts = integration.head_inserts(&ctx); let combined = inserts.join(""); - assert!(combined.contains("__tsAdInit"), "should define __tsAdInit"); + assert!(combined.contains("ts.adInit"), "should define tsjs.adInit"); assert!( - combined.contains("window.__ts_bids"), - "should read window.__ts_bids synchronously" + combined.contains("ts.bids"), + "should read tsjs.bids synchronously" ); assert!( combined.contains("ts_initial"), @@ -1110,13 +1130,10 @@ mod tests { }; let combined = integration.head_inserts(&ctx).join(""); assert!( - combined.contains("__tsServicesEnabled"), - "should guard enableServices/enableSingleRequest with the __tsServicesEnabled flag" - ); - assert!( - combined.contains("window.__tsAdInit"), - "should install __tsAdInit on window" + combined.contains("ts.servicesEnabled"), + "should guard enableServices/enableSingleRequest with the tsjs.servicesEnabled flag" ); + assert!(combined.contains("ts.adInit"), "should install tsjs.adInit"); assert!( !combined.contains("googletag.pubads().refresh()"), "should never call unbounded refresh() — only refresh(newSlots)" @@ -1131,4 +1148,59 @@ mod tests { "gpt" ); } + + #[test] + fn head_inserts_emits_slim_prebid_url_when_configured() { + let config = GptConfig { + slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()), + ..test_config() + }; + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 3, + "should emit three head inserts when slim_prebid_url is set" + ); + assert_eq!( + inserts[2], + r#""#, + "should emit the slim-Prebid URL as a JSON-encoded string assignment" + ); + } + + #[test] + fn head_inserts_omits_slim_prebid_url_when_not_configured() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 2, + "should emit exactly two head inserts when slim_prebid_url is absent" + ); + assert!( + inserts + .iter() + .all(|s| !s.contains("__tsjs_slim_prebid_url")), + "should not emit slim-Prebid URL tag when not configured" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 85109d72..0c7ea0dd 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -1,88 +1,108 @@ // Edge-injected GPT auction bootstrap. // -// This is the minimal `window.__tsAdInit` that runs on first page load +// This is the minimal `window.tsjs.adInit` that runs on first page load // before the TSJS bundle has had a chance to install its richer // idempotent implementation. The bundle in -// crates/js/lib/src/integrations/gpt/index.ts overwrites `__tsAdInit` +// crates/js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` // once it loads. // // Contract with the bundle: -// - Both implementations must set `window.__tsServicesEnabled = true` +// - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a -// subsequent call from any source (the bundle's `__tsAdInit`, the -// publisher's own GPT init code) becomes a no-op. +// subsequent call becomes a no-op. // - `refresh()` is called only for the slots defined in this pass, -// never the global slot list, so we never accidentally refresh -// publisher-managed slots that we don't own. +// never the global slot list. // -// Only installed if `window.__tsAdInit` isn't already defined — that -// way the bundle (or anything else) can preempt this fallback by -// installing first. +// Only installed if `window.tsjs.adInit` isn't already defined. (function () { - if (typeof window === "undefined" || window.__tsAdInit) { - return; - } - window.__tsAdInit = function () { - var slots = window.__ts_ad_slots || []; - var bids = window.__ts_bids || {}; + if (typeof window === "undefined") return; + var ts = (window.tsjs = window.tsjs || {}); + if (ts.adInit) return; + + ts.adInit = function () { + var slots = ts.adSlots || []; + var bids = ts.bids || {}; var divToSlotId = {}; + googletag.cmd.push(function () { + // Slots TS defined itself — tracked for SPA destroy. Publisher-owned + // slots are reused but never destroyed by TS on navigation. var newSlots = []; + // All slots to refresh (TS-defined + publisher-owned reused). + var slotsToRefresh = []; slots.forEach(function (slot) { - var s = googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - slot.div_id, - ); - if (!s) return; - s.addService(googletag.pubads()); + // Resolve actual div ID: exact match first, then prefix query. + // div_id in config may be a stable prefix (e.g. "ad-header-0-") when + // the suffix is dynamically generated by the framework at render time. + var el = + document.getElementById(slot.div_id) || + document.querySelector( + "[id^='" + slot.div_id + "']:not([id$='-container'])", + ); + if (!el) return; + var actualDivId = el.id; + var b = bids[slot.id] || {}; + + var existingSlots = googletag.pubads().getSlots(); + var s = + existingSlots.find(function (gs) { + return gs.getSlotElementId() === actualDivId; + }) || null; + var tsOwned = false; + if (!s) { + // Use outer container div for TS's slot when publisher hasn't defined + // theirs yet — keeps both slots on separate divs so publisher's + // later defineSlot on the inner div doesn't conflict. + var containerEl = document.getElementById(actualDivId + "-container"); + var slotDivId = containerEl ? containerEl.id : actualDivId; + s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slotDivId); + if (!s) return; + s.addService(googletag.pubads()); + tsOwned = true; + } + Object.entries(slot.targeting || {}).forEach(function (e) { s.setTargeting(e[0], e[1]); }); - var b = bids[slot.id] || {}; - ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { + [ + "hb_pb", + "hb_bidder", + "hb_adid", + "hb_cache_host", + "hb_cache_path", + ].forEach(function (k) { if (b[k]) s.setTargeting(k, b[k]); }); + // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - divToSlotId[slot.div_id] = slot.id; - newSlots.push(s); + divToSlotId[actualDivId] = slot.id; + if (tsOwned) newSlots.push(s); + slotsToRefresh.push(s); }); - // Expose slot metadata on window so later calls (SPA navigation, - // the bundle's __tsAdInit) can destroy stale slots and the render - // listener can resolve slot IDs after navigation updates these maps. - window.__tsPrevGptSlots = newSlots; - window.__tsDivToSlotId = divToSlotId; - // Guard the one-time-per-page setup so a follow-up call (e.g. - // publisher's own init code or the bundle's `__tsAdInit` after - // it overwrites this stub) doesn't double-enable services. - if (!window.__tsServicesEnabled) { + ts.prevGptSlots = newSlots; + ts.divToSlotId = divToSlotId; + if (!ts.servicesEnabled) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); - window.__tsServicesEnabled = true; - googletag - .pubads() - .addEventListener("slotRenderEnded", function (ev) { - var divId = ev.slot.getSlotElementId(); - // Read from window so SPA navigation updates are picked up; - // early-return for slots not managed by Trusted Server. - var slotId = (window.__tsDivToSlotId || {})[divId]; - if (!slotId) return; - var b = (window.__ts_bids || {})[slotId] || {}; - // Prebid: verify the specific creative via hb_adid targeting. - // APS: no hb_adid — fire if any TS bidder is present and slot is non-empty. - var ourBidWon = - !ev.isEmpty && - (b.hb_adid - ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid - : !!b.hb_bidder); - if (ourBidWon) { - if (b.nurl) navigator.sendBeacon(b.nurl); - if (b.burl) navigator.sendBeacon(b.burl); - } - }); + ts.servicesEnabled = true; + googletag.pubads().addEventListener("slotRenderEnded", function (ev) { + var divId = ev.slot.getSlotElementId(); + var slotId = (ts.divToSlotId || {})[divId]; + if (!slotId) return; + var b = (ts.bids || {})[slotId] || {}; + var ourBidWon = + !ev.isEmpty && + (b.hb_adid + ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid + : !!b.hb_bidder); + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl); + if (b.burl) navigator.sendBeacon(b.burl); + } + }); } - if (newSlots.length > 0) { - googletag.pubads().refresh(newSlots); + if (slotsToRefresh.length > 0) { + googletag.pubads().refresh(slotsToRefresh); } }); }; diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index b74b234c..1ab937ba 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -8,6 +8,7 @@ use fastly::http::{header, Method, StatusCode, Url}; use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; +use url::Url as ParsedUrl; use validator::Validate; use crate::auction::provider::AuctionProvider; @@ -1374,6 +1375,49 @@ impl PrebidAuctionProvider { .collect() }); + // Extract PBS Cache coordinates from ext.prebid.cache.bids + let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + + let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + + let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + ParsedUrl::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {}", e)) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + // path() returns "/" for root — only use if non-trivial + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { + None + } else { + Some(path) + }; + (host, path) + }) + .unwrap_or((None, None)); + + // Guard: if we extracted a cache UUID but couldn't extract the host, + // the bid will have hb_adid set but no endpoint to fetch from — creative will fail. + if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{}'", + slot_id + ); + } + Ok(AuctionBid { slot_id, price: Some(price), // Prebid provides decoded prices @@ -1386,6 +1430,9 @@ impl PrebidAuctionProvider { nurl, burl, ad_id, + cache_id, + cache_host, + cache_path, metadata: std::collections::HashMap::new(), }) } @@ -4339,4 +4386,137 @@ set = { networkId = 42 } "should fail fast when a canonical rule has no matcher fields" ); } + + #[test] + fn parse_bid_extracts_cache_id_from_ext_prebid_cache_bids() { + let bid_json = serde_json::json!({ + "id": "bid-id-123", + "impid": "atf_sidebar_ad", + "price": 1.50, + "adm": "
ad
", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "thetradedesk") + .expect("should parse bid"); + assert_eq!( + bid.cache_id.as_deref(), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should extract cacheId as cache_id" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("openads.adsrvr.org"), + "should extract host from cache URL" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should extract path from cache URL" + ); + } + + #[test] + fn parse_bid_sets_cache_fields_to_none_when_no_cache_entry() { + let bid_json = serde_json::json!({ + "id": "bid-id-456", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250 + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert!(bid.cache_id.is_none(), "should be None when cache absent"); + assert!(bid.cache_host.is_none(), "should be None when cache absent"); + assert!(bid.cache_path.is_none(), "should be None when cache absent"); + } + + #[test] + fn parse_bid_handles_malformed_cache_url_gracefully() { + let bid_json = serde_json::json!({ + "id": "bid-id-789", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "not-a-valid-url", + "cacheId": "some-uuid" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid without panicking"); + assert_eq!( + bid.cache_id.as_deref(), + Some("some-uuid"), + "should still extract cacheId even if URL is malformed" + ); + assert!( + bid.cache_host.is_none(), + "should be None when URL parse fails" + ); + assert!( + bid.cache_path.is_none(), + "should be None when URL parse fails" + ); + } + + #[test] + fn parse_bid_preserves_ad_id_alongside_cache_id() { + let bid_json = serde_json::json!({ + "id": "bid-impression-id", + "impid": "atf_sidebar_ad", + "adid": "bidder-ad-id-abc", + "price": 1.0, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://cache.example.com/cache", + "cacheId": "cache-uuid-xyz" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert_eq!( + bid.ad_id.as_deref(), + Some("bidder-ad-id-abc"), + "should keep ad_id from adid field" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid-xyz"), + "should extract cache UUID separately" + ); + } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c6ffa776..12a368c4 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -428,7 +428,7 @@ pub struct OwnedProcessResponseParams { /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. pub(crate) dispatched_auction: Option, - /// Price granularity used to bucket bids when building `__ts_bids`. + /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, } @@ -516,6 +516,7 @@ pub async fn stream_publisher_body_async( &result.winning_bids, params.price_granularity, ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -611,13 +612,14 @@ pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, + inject_adm: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity); + let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -757,7 +759,12 @@ async fn one_behind_loop( "one_behind_loop: collect complete — {} winning bid(s)", result.winning_bids.len() ); - write_bids_to_state(&result.winning_bids, price_granularity, ad_bids_state); + write_bids_to_state( + &result.winning_bids, + price_granularity, + ad_bids_state, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("stream", &result, ad_bids_state); @@ -853,7 +860,7 @@ pub async fn handle_publisher_request( integration_registry: &IntegrationRegistry, services: &RuntimeServices, orchestrator: &AuctionOrchestrator, - slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], mut req: Request, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -939,7 +946,7 @@ pub async fn handle_publisher_request( let is_bot = is_bot_user_agent(&req); let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { - crate::creative_opportunities::match_slots(&slots_file.slots, &request_path) + crate::creative_opportunities::match_slots(slots, &request_path) .into_iter() .cloned() .collect() @@ -1192,7 +1199,12 @@ pub async fn handle_publisher_request( "BufferedProcessed: auction collected — {} winning bid(s)", result.winning_bids.len() ); - write_bids_to_state(&result.winning_bids, price_granularity, &ad_bids_state); + write_bids_to_state( + &result.winning_bids, + price_granularity, + &ad_bids_state, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("buffered", &result, &ad_bids_state); @@ -1311,6 +1323,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, + include_adm: bool, ) -> serde_json::Map { winning_bids .iter() @@ -1323,10 +1336,30 @@ pub(crate) fn build_bid_map( "hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone()), ); - if let Some(ref ad_id) = bid.ad_id { + // hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses + // this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to + // bid.ad_id for APS and other non-PBS providers. + let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); + if let Some(id) = hb_adid { obj.insert( "hb_adid".to_string(), - serde_json::Value::String(ad_id.clone()), + serde_json::Value::String(id.to_string()), + ); + } + + // Cache endpoint coordinates — only present for PBS bids with Prebid Cache enabled. + // The Prebid Universal Creative constructs: + // https://?uuid= + if let Some(ref host) = bid.cache_host { + obj.insert( + "hb_cache_host".to_string(), + serde_json::Value::String(host.clone()), + ); + } + if let Some(ref path) = bid.cache_path { + obj.insert( + "hb_cache_path".to_string(), + serde_json::Value::String(path.clone()), ); } if let Some(ref nurl) = bid.nurl { @@ -1335,13 +1368,40 @@ pub(crate) fn build_bid_map( if let Some(ref burl) = bid.burl { obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); } + // Include raw creative markup only for explicit debug injection. + // The pbRender bridge can use it while PBS Cache is unavailable. + if include_adm { + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + obj.insert( + "debug_bid".to_string(), + serde_json::json!({ + "slot_id": bid.slot_id, + "price": bid.price, + "currency": bid.currency, + "creative": bid.creative, + "adomain": bid.adomain, + "bidder": bid.bidder, + "width": bid.width, + "height": bid.height, + "nurl": bid.nurl, + "burl": bid.burl, + "ad_id": bid.ad_id, + "cache_id": bid.cache_id, + "cache_host": bid.cache_host, + "cache_path": bid.cache_path, + "metadata": bid.metadata, + }), + ); + } (slot_id.clone(), serde_json::Value::Object(obj)) }) }) .collect() } -/// Build the `__ts_bids` `` sequences inside the string. @@ -1350,7 +1410,7 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map should be infallible"); let escaped = html_escape_for_script(&json); format!( - "", + "", escaped ) } @@ -1363,7 +1423,7 @@ pub(crate) fn build_empty_bids_script() -> String { build_bids_script(&serde_json::Map::new()) } -/// Build the `__ts_ad_slots` `", + "", escaped ) } @@ -1479,7 +1539,7 @@ pub async fn handle_page_bids( settings: &Settings, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, - slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], req: Request, ) -> Result> { let Some(co_config) = &settings.creative_opportunities else { @@ -1494,11 +1554,10 @@ pub async fn handle_page_bids( .map(|(_, v)| v.into_owned()) .unwrap_or_else(|| "/".to_string()); - let matched_slots: Vec<_> = - crate::creative_opportunities::match_slots(&slots_file.slots, &path_param) - .into_iter() - .cloned() - .collect(); + let matched_slots: Vec<_> = crate::creative_opportunities::match_slots(slots, &path_param) + .into_iter() + .cloned() + .collect(); let http_req = compat::from_fastly_headers_ref(&req); let request_info = @@ -1600,7 +1659,11 @@ pub async fn handle_page_bids( std::collections::HashMap::new() }; - let bid_map = build_bid_map(&winning_bids, co_config.price_granularity); + let bid_map = build_bid_map( + &winning_bids, + co_config.price_granularity, + settings.debug.inject_adm_for_testing, + ); let slots_json: Vec = matched_slots .iter() @@ -2582,6 +2645,7 @@ mod tests { gam_network_id: "21765378893".to_string(), auction_timeout_ms: Some(500), price_granularity: PriceGranularity::Dense, + slot: Vec::new(), } } @@ -2625,6 +2689,9 @@ mod tests { nurl: Some(nurl.to_string()), burl: Some(burl.to_string()), ad_id: Some(ad_id.to_string()), + cache_id: None, + cache_host: None, + cache_path: None, metadata: Default::default(), } } @@ -2635,11 +2702,15 @@ mod tests { let config = make_config(); let script = build_ad_slots_script(&slots, &config); assert!( - script.contains("window.__ts_ad_slots=JSON.parse"), - "should use JSON.parse" + script.contains("window.tsjs=window.tsjs||{}"), + "should initialise tsjs namespace" + ); + assert!( + script.contains(".adSlots=JSON.parse"), + "should use JSON.parse for adSlots" ); assert!(script.contains("atf_sidebar_ad"), "should include slot id"); - assert!(!script.contains("__ts_bids"), "must NOT contain bids"); + assert!(!script.contains("adInit"), "must NOT contain adInit"); assert!( !script.contains("__ts_request_id"), "must NOT contain request_id" @@ -2672,7 +2743,7 @@ mod tests { "https://ssp/bill", ), ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); let obj = entry.as_object().expect("should be object"); assert_eq!( @@ -2688,7 +2759,7 @@ mod tests { assert_eq!( obj.get("hb_adid").and_then(|v| v.as_str()), Some("abc123"), - "should include ad_id" + "should fall back to ad_id when no cache_id present" ); assert_eq!( obj.get("nurl").and_then(|v| v.as_str()), @@ -2702,6 +2773,250 @@ mod tests { ); } + #[test] + fn client_bid_map_omits_adm_by_default() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert!( + obj.get("adm").is_none(), + "should omit adm when debug injection is disabled" + ); + assert!( + obj.get("debug_bid").is_none(), + "should omit debug bid when debug injection is disabled" + ); + } + + #[test] + fn client_bid_map_includes_adm_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("adm").and_then(|v| v.as_str()), + Some("
Creative
"), + "should include adm when debug injection is enabled" + ); + } + + #[test] + fn client_bid_map_includes_debug_bid_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "mocktioneer", + "bid-ad-id", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + bid.adomain = Some(vec!["example.com".to_string()]); + bid.cache_id = Some("cache-uuid".to_string()); + bid.cache_host = Some("cache.example".to_string()); + bid.cache_path = Some("/cache".to_string()); + bid.metadata.insert( + "raw_field".to_string(), + serde_json::Value::String("raw-value".to_string()), + ); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + let debug_bid = obj + .get("debug_bid") + .and_then(|v| v.as_object()) + .expect("should include debug bid when debug injection is enabled"); + + assert_eq!( + debug_bid.get("slot_id").and_then(|v| v.as_str()), + Some("atf_sidebar_ad"), + "should expose original slot id" + ); + assert_eq!( + debug_bid.get("bidder").and_then(|v| v.as_str()), + Some("mocktioneer"), + "should expose original bidder" + ); + assert_eq!( + debug_bid.get("ad_id").and_then(|v| v.as_str()), + Some("bid-ad-id"), + "should expose original bid ad id" + ); + assert_eq!( + debug_bid.get("cache_id").and_then(|v| v.as_str()), + Some("cache-uuid"), + "should expose original PBS cache id" + ); + assert_eq!( + debug_bid.get("metadata").and_then(|v| v.get("raw_field")), + Some(&serde_json::Value::String("raw-value".to_string())), + "should expose provider metadata" + ); + } + + #[test] + fn bid_map_uses_cache_id_for_hb_adid_when_present() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), + cache_host: Some("openads.adsrvr.org".to_string()), + cache_path: Some("/cache".to_string()), + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should use cache_id for hb_adid, not ad_id" + ); + assert_eq!( + obj.get("hb_cache_host").and_then(|v| v.as_str()), + Some("openads.adsrvr.org"), + "should emit hb_cache_host" + ); + assert_eq!( + obj.get("hb_cache_path").and_then(|v| v.as_str()), + Some("/cache"), + "should emit hb_cache_path" + ); + } + + #[test] + fn bid_map_falls_back_to_ad_id_when_cache_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("aps-bid-token".to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("aps-bid-token"), + "should fall back to ad_id when cache_id absent" + ); + assert!( + obj.get("hb_cache_host").is_none(), + "should not emit hb_cache_host when absent" + ); + assert!( + obj.get("hb_cache_path").is_none(), + "should not emit hb_cache_path when absent" + ); + } + + #[test] + fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert!( + obj.get("hb_adid").is_none(), + "should omit hb_adid when no cache_id and no ad_id" + ); + } + #[test] fn bid_map_excludes_slot_when_price_is_none() { let mut winning_bids = HashMap::new(); @@ -2719,10 +3034,13 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); assert!( map.is_empty(), "slot with no price should be excluded from bid map" @@ -2789,9 +3107,7 @@ mod tests { mod page_bids_no_match_tests { use super::super::*; use crate::auction::AuctionOrchestrator; - use crate::creative_opportunities::{ - CreativeOpportunitiesFile, CreativeOpportunityFormat, CreativeOpportunitySlot, - }; + use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; use fastly::http::Method; @@ -2805,24 +3121,22 @@ mod tests { Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } - fn file_with_article_slot() -> CreativeOpportunitiesFile { - CreativeOpportunitiesFile { - slots: vec![CreativeOpportunitySlot { - id: "atf".to_string(), - gam_unit_path: None, - div_id: None, - page_patterns: vec!["/20**".to_string()], - formats: vec![CreativeOpportunityFormat { - width: 300, - height: 250, - media_type: crate::auction::types::MediaType::Banner, - }], - floor_price: Some(0.50), - targeting: Default::default(), - providers: Default::default(), - compiled_patterns: Vec::new(), + fn article_slot() -> Vec { + vec![CreativeOpportunitySlot { + id: "atf".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: crate::auction::types::MediaType::Banner, }], - } + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + }] } fn make_page_bids_request(path: &str) -> Request { @@ -2839,10 +3153,9 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = CreativeOpportunitiesFile { slots: vec![] }; let req = make_page_bids_request("/2024/01/my-article/"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &[], req) .await .expect("should return ok response"); @@ -2855,7 +3168,7 @@ mod tests { .expect("slots should be array") .len(), 0, - "empty slots file should produce zero injected slots" + "empty slots should produce zero injected slots" ); assert_eq!( body["bids"] @@ -2863,7 +3176,7 @@ mod tests { .expect("bids should be object") .len(), 0, - "empty slots file should produce zero bids" + "empty slots should produce zero bids" ); } @@ -2875,11 +3188,11 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); + let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("user-agent", "Mozilla/5.0 (compatible; Googlebot/2.1)"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); @@ -2911,11 +3224,11 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); + let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("sec-purpose", "prefetch"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); @@ -2946,10 +3259,10 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); // slot matches /20** only + let slots = article_slot(); // slot matches /20** only let req = make_page_bids_request("/about"); // does not match - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 386f0d54..b221e0ea 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -416,6 +416,15 @@ pub struct DebugConfig { /// Never enable in production — visible in page source. #[serde(default)] pub auction_html_comment: bool, + + /// Include raw `adm` creative markup in `window.tsjs.bids` for GPT/GAM + /// debug rendering through the Prebid Universal Creative bridge. + /// + /// Use this to validate the server-side auction→GAM targeting→creative + /// rendering pipeline while PBS Cache is unavailable. Never enable in + /// production — injects raw HTML from SSPs. + #[serde(default)] + pub inject_adm_for_testing: bool, } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -522,14 +531,29 @@ impl Settings { /// # Errors /// /// Returns a configuration error if any cached runtime artifact cannot be prepared. - pub fn prepare_runtime(&self) -> Result<(), Report> { + pub fn prepare_runtime(&mut self) -> Result<(), Report> { for handler in &self.handlers { handler.prepare_runtime()?; } + if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + } + Ok(()) } + /// Returns compiled creative opportunity slots, or empty slice if feature is disabled. + #[must_use] + pub fn creative_opportunity_slots( + &self, + ) -> &[crate::creative_opportunities::CreativeOpportunitySlot] { + self.creative_opportunities + .as_ref() + .map(|co| co.slot.as_slice()) + .unwrap_or(&[]) + } + /// Resolve the first handler whose regex matches the request path. /// /// # Errors diff --git a/creative-opportunities.toml b/creative-opportunities.toml deleted file mode 100644 index da1ed23e..00000000 --- a/creative-opportunities.toml +++ /dev/null @@ -1,47 +0,0 @@ -# Slot templates for server-side ad auction. -# Empty file = feature disabled (no auction fired, no globals injected). - -[[slot]] -id = "atf_sidebar_ad" -gam_unit_path = "/a/b/news" -div_id = "ad-atf_sidebar-0-_r_2_" -page_patterns = ["/20**", "/news/**"] -formats = [{ width = 300, height = 250 }] -floor_price = 0.50 - -[slot.targeting] -pos = "atf" -zone = "atfSidebar" - -[slot.providers.aps] -slot_id = "aps-slot-atf-sidebar" - -[[slot]] -id = "homepage_header_ad" -gam_unit_path = "/a/b/homepage" -div_id = "ad-header-0-_R_jpalubtak5lb_" -page_patterns = ["/"] -formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] -floor_price = 0.50 - -[slot.targeting] -pos = "atf" -zone = "header" - -[slot.providers.aps] -slot_id = "aps-slot-homepage-header" - -[[slot]] -id = "homepage_footer_ad" -gam_unit_path = "/a/b/homepage" -div_id = "ad-fixed_bottom-0-_R_klubtak5lb_" -page_patterns = ["/"] -formats = [{ width = 728, height = 90 }] -floor_price = 0.50 - -[slot.targeting] -pos = "btf" -zone = "fixedBottom" - -[slot.providers.aps] -slot_id = "aps-slot-homepage-footer" diff --git a/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md new file mode 100644 index 00000000..83866e3b --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md @@ -0,0 +1,630 @@ +# PR #680 Reviewer Findings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Address the two reviewer-required findings from PR #680 plus low-effort cleanups: consolidate slot config into `trusted-server.toml`, consolidate `window.__ts*` globals under `window.tsjs`, and fix the TypeScript `formats` type cast and `ts_initial` hardcoded string. + +**Architecture:** Slot templates move from the standalone `creative-opportunities.toml` (embedded via `include_str!`) into the `[creative_opportunities]` section of `trusted-server.toml`, using the existing `vec_from_seq_or_map` deserializer pattern already used for `BID_PARAM_ZONE_OVERRIDES`. The window globals rename is a coordinated change across `gpt_bootstrap.js`, `index.ts`, and `publisher.rs` — all three must change together since they share a runtime contract. + +**Tech Stack:** Rust (serde, toml), TypeScript, vanilla JS, `cargo test --workspace`, `npx vitest run` + +--- + +## Context for all tasks + +- **Branch:** create `fix/pr680-review-findings` off `server-side-ad-templates-impl` before starting +- **Current codebase:** `crates/trusted-server-core/`, `crates/trusted-server-adapter-fastly/`, `crates/js/lib/` +- **CI gates:** `cargo fmt`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace`, `npx vitest run`, `npm run format` +- **Error handling:** use `error-stack` (`Report`), not anyhow. Use `derive_more::Display`, not thiserror. +- **No `unwrap()` in production code** — use `expect("should ...")`. +- **Do not** add `println!` / `eprintln!` — use `log::` macros. + +--- + +## Task 1: Consolidate slot config into `trusted-server.toml` + +**What:** Delete `creative-opportunities.toml`. Move `[[slot]]` arrays into `trusted-server.toml` as `[[creative_opportunities.slot]]`. Wire the `vec_from_seq_or_map` deserializer so env var JSON blobs also work. Remove the `SLOTS_FILE` static and `include_str!` from `main.rs`. Update `build.rs` to validate slot IDs from settings instead of a separate file. + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` +- Modify: `crates/trusted-server-core/build.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` (function signatures) +- Modify: `trusted-server.toml` +- Delete: `creative-opportunities.toml` + +**Steps:** + +- [ ] **Step 1: Create the branch** + +```bash +git checkout -b fix/pr680-review-findings +``` + +- [ ] **Step 2: Add `Serialize` and `slot` field to structs** + +In `crates/trusted-server-core/src/creative_opportunities.rs`: + +1. Add `Serialize` to `CreativeOpportunitySlot` derive — it already has `#[serde(skip, default)]` on `compiled_patterns` so that field won't serialize. + +```rust +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunitySlot { ... } +``` + +Also add `Serialize` to `CreativeOpportunityFormat`, `SlotProviders`, `ApsSlotParams` (any struct used inside `CreativeOpportunitySlot`). + +2. Add a `slot` field to `CreativeOpportunitiesConfig`: + +```rust +use crate::settings::vec_from_seq_or_map; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default = "PriceGranularity::dense")] + pub price_granularity: PriceGranularity, + /// Slot templates. Empty = feature disabled. + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub slot: Vec, +} +``` + +Note: the field is named `slot` (not `slots`) to match the TOML key `[[creative_opportunities.slot]]`. + +- [ ] **Step 3: Delete `CreativeOpportunitiesFile`** + +Remove the `CreativeOpportunitiesFile` struct and its `impl` from `creative_opportunities.rs`. The `compile` logic moves to a free function or into `CreativeOpportunitiesConfig`: + +```rust +impl CreativeOpportunitiesConfig { + /// Pre-compile glob patterns for all slots. Call once after deserialization. + pub fn compile_slots(&mut self) { + for slot in &mut self.slot { + slot.compile_patterns(); + } + } +} +``` + +- [ ] **Step 4: Wire slot compilation into `Settings::prepare_runtime`** + +Glob pattern pre-compilation must happen once at startup, not per-request. `Settings::prepare_runtime` is already called after deserialization in both `from_toml_and_env` (build time) and `get_settings()` (runtime). Add slot compilation there: + +```rust +// In settings.rs, inside Settings::prepare_runtime +pub fn prepare_runtime(&mut self) -> Result<(), Report> { + for handler in &self.handlers { + handler.prepare_runtime()?; + } + // Pre-compile slot glob patterns for hot-path matching. + if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + } + Ok(()) +} +``` + +Note: `prepare_runtime` must take `&mut self` for this change. Check current signature — if it takes `&self`, change it to `&mut self` and update call sites. + +Also add a helper method for call sites that need the slot slice: + +```rust +impl Settings { + /// Returns compiled creative opportunity slots, or empty slice if disabled. + pub fn creative_opportunity_slots(&self) -> &[CreativeOpportunitySlot] { + self.creative_opportunities + .as_ref() + .map(|co| co.slot.as_slice()) + .unwrap_or(&[]) + } +} +``` + +- [ ] **Step 5: Update `build.rs` stub and slot validation** + +First update the `creative_opportunities` stub in `build.rs` to add the `slot` field — without this the settings parse will fail at build time when `trusted-server.toml` contains `[[creative_opportunities.slot]]` entries: + +```rust +mod creative_opportunities { + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Deserialize, Serialize)] + pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default = "default_price_granularity")] + pub price_granularity: String, + // Use serde_json::Value to avoid pulling in full slot type in build context. + #[serde(default)] + pub slot: Vec, + } + + fn default_price_granularity() -> String { + "dense".to_string() + } +} +``` + +Then replace the separate-file validation block with reading slots from `Settings`: + +```rust +// After settings are parsed, validate slot IDs +let slot_id_re = regex::Regex::new(r"^[A-Za-z0-9_\-]+$").expect("should compile regex"); +if let Some(co) = &settings.creative_opportunities { + for slot in &co.slot { + if let Err(e) = trusted_server_core::creative_opportunities::validate_slot_id(&slot.id) { + panic!("trusted-server.toml [creative_opportunities.slot]: {e}"); + } + } + if !co.slot.is_empty() { + println!( + "cargo:warning=creative_opportunities: {} slot(s) validated", + co.slot.len() + ); + } +} +``` + +Remove: `CREATIVE_OPPORTUNITIES_PATH` const, the `co_path.exists()` block, and the `println!("cargo:rerun-if-changed={}", CREATIVE_OPPORTUNITIES_PATH)` line. + +Note: `build.rs` already pulls in `src/creative_opportunities.rs` as a module — make sure the module stub includes the new `Serialize` derive (it may need the serde `Serialize` import). + +- [ ] **Step 6: Update `main.rs` — remove `SLOTS_FILE` static** + +Remove: + +```rust +const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); +static SLOTS_FILE: std::sync::LazyLock<...> = ...; +``` + +Replace `slots_file` parameter threading with deriving slots from `settings`: + +Where `slots_file` was passed as `&*SLOTS_FILE`, pass `settings.creative_opportunity_slots()` instead. This requires `settings` to be available at that call site (it is — `settings` is already in scope). + +Update function signatures in `main.rs` that reference `CreativeOpportunitiesFile` to accept `&[CreativeOpportunitySlot]` instead. + +- [ ] **Step 7: Update `publisher.rs` function signatures** + +Functions that take `&crate::creative_opportunities::CreativeOpportunitiesFile` change to `&[crate::creative_opportunities::CreativeOpportunitySlot]`: + +```rust +// Before +pub(crate) fn handle_page_bids( + ... + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + ... +) + +// After +pub(crate) fn handle_page_bids( + ... + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + ... +) +``` + +Inside the function body, replace `slots_file.slots` with `slots`. + +Update all call sites and test helpers in `publisher.rs` that construct `CreativeOpportunitiesFile { slots: vec![...] }` to pass `&[slot]` directly. + +- [ ] **Step 8: Update `trusted-server.toml`** + +Move the slots from `creative-opportunities.toml` into `trusted-server.toml` under `[creative_opportunities]`. Use `[[creative_opportunities.slot]]` syntax. Use only example/fictional values per project convention (example.com domains, fictional IDs): + +```toml +[creative_opportunities] +gam_network_id = "88059007" +auction_timeout_ms = 1500 +price_granularity = "dense" + +[[creative_opportunities.slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/a/b/news" +div_id = "div-ad-atf-sidebar" +page_patterns = ["/news/**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" +``` + +- [ ] **Step 9: Delete `creative-opportunities.toml`** + +```bash +git rm creative-opportunities.toml +``` + +- [ ] **Step 10: Run tests** + +```bash +cargo test --workspace +``` + +Expected: all tests pass. Fix any compile errors from the signature changes. + +- [ ] **Step 11: Run clippy and fmt** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 12: Commit** + +```bash +git add -p +git commit -m "Move slot templates from creative-opportunities.toml into trusted-server.toml" +``` + +--- + +## Task 2: Consolidate `window.__ts*` globals under `window.tsjs` + +**What:** All `window.__ts*` globals become properties on a single `window._ts` namespace object. Changes must be coordinated across three files: `gpt_bootstrap.js`, `index.ts`, and `publisher.rs`. Tests in `index.test.ts` must be updated too. + +**Rename table:** + +| Old global | New property | Notes | +| ----------------------------- | ------------------------------ | ---------------------------- | +| `window.__ts_ad_slots` | `window.tsjs.adSlots` | Array, set at head-open | +| `window.__ts_bids` | `window.tsjs.bids` | Object, set before `` | +| `window.__tsAdInit` | `window.tsjs.adInit` | Function | +| `window.__tsPrevGptSlots` | `window.tsjs.prevGptSlots` | Array | +| `window.__tsServicesEnabled` | `window.tsjs.servicesEnabled` | Boolean | +| `window.__tsDivToSlotId` | `window.tsjs.divToSlotId` | Object | +| `window.__tsSpaHookInstalled` | `window.tsjs.spaHookInstalled` | Boolean | + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/js/lib/src/integrations/gpt/index.test.ts` +- Modify: `crates/js/lib/test/integrations/gpt/index.test.ts` (if exists) + +**Steps:** + +- [ ] **Step 1: Update `publisher.rs` injected scripts** + +`build_ad_slots_script` generates the `", escaped) + +// After — initialise _ts if absent, then set adSlots +format!("", escaped) +``` + +`build_bids_script` generates the script injected before ``. Change: + +```rust +// Before +format!( + "", + escaped +) + +// After +format!( + "", + escaped +) +``` + +Note: `{{}}` is the Rust format-string escape for a literal `{}`. + +Update any test assertions in `publisher.rs` that check for the old global names. + +- [ ] **Step 2: Update `gpt_bootstrap.js`** + +Replace all `window.__ts*` references. The bootstrap IIFE runs before the TS bundle, so it must initialise `window._ts` if absent: + +```js +;(function () { + if (typeof window === 'undefined') return + // Initialise namespace; adInit guard prevents double-install. + var ts = (window._ts = window._ts || {}) + if (ts.adInit) return + + ts.adInit = function () { + var slots = ts.adSlots || [] + var bids = ts.bids || {} + var divToSlotId = {} + googletag.cmd.push(function () { + var newSlots = [] + slots.forEach(function (slot) { + var s = googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + slot.div_id + ) + if (!s) return + s.addService(googletag.pubads()) + Object.entries(slot.targeting || {}).forEach(function (e) { + s.setTargeting(e[0], e[1]) + }) + var b = bids[slot.id] || {} + ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]) + }) + s.setTargeting('ts_initial', '1') + divToSlotId[slot.div_id] = slot.id + newSlots.push(s) + }) + ts.prevGptSlots = newSlots + ts.divToSlotId = divToSlotId + if (!ts.servicesEnabled) { + googletag.pubads().enableSingleRequest() + googletag.enableServices() + ts.servicesEnabled = true + googletag.pubads().addEventListener('slotRenderEnded', function (ev) { + var divId = ev.slot.getSlotElementId() + var slotId = (ts.divToSlotId || {})[divId] + if (!slotId) return + var b = (ts.bids || {})[slotId] || {} + var ourBidWon = + !ev.isEmpty && + (b.hb_adid + ? ev.slot.getTargeting('hb_adid')[0] === b.hb_adid + : !!b.hb_bidder) + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl) + if (b.burl) navigator.sendBeacon(b.burl) + } + }) + } + if (newSlots.length > 0) { + googletag.pubads().refresh(newSlots) + } + }) + } +})() +``` + +- [ ] **Step 3: Update `index.ts` — rename `TsWindow` type** + +Replace the `TsWindow` interface: + +```typescript +type TsNamespace = { + adSlots?: TsAdSlot[] + bids?: Record + adInit?: () => void + prevGptSlots?: GoogleTagSlot[] + servicesEnabled?: boolean + divToSlotId?: Record + spaHookInstalled?: boolean +} + +type TsWindow = Window & { + _ts?: TsNamespace +} +``` + +- [ ] **Step 4: Update `installTsAdInit` in `index.ts`** + +Update all properties to live under `window.tsjs`. Use `window.tsjs` directly: + +```typescript +export function installTsAdInit(): void { + const w = window as TsWindow + const ts = (w._ts = w._ts ?? {}) + ts.adInit = function () { + const slots = ts.adSlots ?? [] + const bids = ts.bids ?? {} + const g = (window as GptWindow).googletag + if (!g) return + + g.cmd?.push(() => { + if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + g.destroySlots?.(ts.prevGptSlots) + ts.prevGptSlots = [] + } + const newSlots: GoogleTagSlot[] = [] + const divToSlotId: Record = {} + + slots.forEach((slot) => { + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats as Array, + slot.div_id + ) + if (!gptSlot) return + gptSlot.addService(g.pubads!()) + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => + gptSlot.setTargeting(k, v) + ) + const bid = bids[slot.id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + gptSlot.setTargeting('ts_initial', '1') + divToSlotId[slot.div_id] = slot.id + newSlots.push(gptSlot) + }) + + ts.prevGptSlots = newSlots + ts.divToSlotId = divToSlotId + + if (!ts.servicesEnabled) { + g.pubads!().enableSingleRequest() + g.enableServices?.() + ts.servicesEnabled = true + g.pubads!().addEventListener?.( + 'slotRenderEnded', + (event: SlotRenderEndedEvent) => { + const divId: string = event.slot?.getSlotElementId?.() ?? '' + const slotId = (ts.divToSlotId ?? {})[divId] + if (!slotId) return + const bid = (ts.bids ?? {})[slotId] ?? {} + const ourBidWon = + !event.isEmpty && + (bid.hb_adid + ? event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + : !!bid.hb_bidder) + if (ourBidWon) { + if (bid.nurl) navigator.sendBeacon(bid.nurl) + if (bid.burl) navigator.sendBeacon(bid.burl) + } + } + ) + } + if (newSlots.length > 0) { + g.pubads!().refresh(newSlots) + } + }) + } +} +``` + +- [ ] **Step 5: Update `installSpaHook` in `index.ts`** + +Replace `__tsSpaHookInstalled` and `__ts_ad_slots`/`__ts_bids` reads: + +```typescript +export function installSpaHook(): void { + const win = window as TsWindow + const ts = (win._ts = win._ts ?? {}) + if (ts.spaHookInstalled) return + ts.spaHookInstalled = true + // ... rest of SPA hook logic uses ts.adSlots, ts.bids, ts.adInit +} +``` + +- [ ] **Step 6: Update tests in `index.test.ts`** + +Find all test assertions that reference `window.__ts_ad_slots`, `window.__ts_bids`, `window.__tsAdInit`, etc. and update to `window.tsjs.adSlots`, `window.tsjs.bids`, `window.tsjs.adInit` etc. + +Run tests first to see what fails: + +```bash +cd crates/js/lib && npx vitest run +``` + +Fix each failing assertion. + +- [ ] **Step 7: Run JS tests and format** + +```bash +cd crates/js/lib && npx vitest run +cd crates/js/lib && npm run format +``` + +Expected: all tests pass, no format errors. + +- [ ] **Step 8: Run Rust tests** + +```bash +cargo test --workspace +``` + +Update any test assertions in `publisher.rs` that check for old global names (e.g. `script.contains("window.__ts_ad_slots")`). + +- [ ] **Step 9: Run clippy and fmt** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 10: Commit** + +```bash +git commit -m "Namespace window globals under window._ts" +``` + +--- + +## Task 3: Fix `formats` type and extract `ts_initial` constant + +**What:** Two small TypeScript/JS cleanups. `TsAdSlot.formats` should be typed as `Array<[number, number]>` (tuple, not array-of-array) to match GPT's actual input. The string `'ts_initial'` is hardcoded in both `gpt_bootstrap.js` and `index.ts` — extract as a named constant in `index.ts` (no JS equivalent needed since the bootstrap is vanilla JS). + +**Files:** + +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` (comment only — JS can't share TS constants) + +**Steps:** + +- [ ] **Step 1: Fix `TsAdSlot.formats` type** + +In `index.ts`, change: + +```typescript +// Before +interface TsAdSlot { + ... + formats: Array; +} + +// After +interface TsAdSlot { + ... + formats: Array<[number, number]>; +} +``` + +Update the cast at the GPT `defineSlot` call site — `[number, number]` satisfies `number | number[]` so the cast can be removed or simplified: + +```typescript +// Before +slot.formats as Array + +// After — [number, number][] already satisfies Array +slot.formats +``` + +- [ ] **Step 2: Extract `ts_initial` constant in `index.ts`** + +Near the top of `index.ts`, add: + +```typescript +const TS_INITIAL_TARGETING_KEY = 'ts_initial' +``` + +Replace both occurrences of `'ts_initial'` in `installTsAdInit` with `TS_INITIAL_TARGETING_KEY`. + +Add a comment in `gpt_bootstrap.js` where `'ts_initial'` appears: + +```js +// Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts +s.setTargeting('ts_initial', '1') +``` + +- [ ] **Step 3: Run JS tests and format** + +```bash +cd crates/js/lib && npx vitest run +cd crates/js/lib && npm run format +``` + +- [ ] **Step 4: Commit** + +```bash +git commit -m "Fix TsAdSlot formats type and extract ts_initial constant" +``` + +--- + +## Final verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace` +- [ ] `cd crates/js/lib && npx vitest run` +- [ ] `cd crates/js/lib && npm run format` +- [ ] `cd docs && npm run format` diff --git a/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md new file mode 100644 index 00000000..7a3f3420 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md @@ -0,0 +1,760 @@ +# Prebid Creative Rendering Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix `hb_adid` to carry the PBS Cache UUID (not the OpenRTB bid ID) so the Prebid Universal Creative in GAM can fetch and render the correct creative markup. + +**Architecture:** Three-file change: add `cache_id`/`cache_host`/`cache_path` fields to the shared `Bid` struct in `types.rs`, extract these from `ext.prebid.cache.bids` in `prebid.rs`'s `parse_bid`, then emit them as `hb_adid`/`hb_cache_host`/`hb_cache_path` in `publisher.rs`'s `build_bid_map`. `AuctionBid` in `prebid.rs` is a type alias for `Bid` (`use ... Bid as AuctionBid`), so only one struct needs the new fields. + +**Tech Stack:** Rust 2024, `serde`, `url` crate (already in workspace deps at v2.5.8), `cargo test --workspace` + +--- + +## Context for all tasks + +- **Branch:** `fix/server-side-ad-template-entrypoint` (already checked out) +- **Spec:** `docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md` +- **Error handling:** `error-stack` (`Report`), not anyhow. Use `expect("should ...")` not `unwrap()`. +- **No `println!`/`eprintln!`** — use `log::` macros. +- **All public items must have doc comments.** +- CI gates: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace` + +--- + +## Task 1: Add cache fields to `Bid` struct and fix all construction sites + +**What:** Add three new `Option` fields to `Bid`. Since Rust struct literals are exhaustive, every place that constructs a `Bid { ... }` in the codebase will fail to compile until the new fields are added. Fix all of them with `None` defaults (except the APS provider which constructs a real `Bid` — also `None` since APS doesn't use PBS Cache). + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs:200` (after `ad_id` field) +- Modify (test helpers/literals — add `None` fields): + - `crates/trusted-server-core/src/auction/types.rs:314` (`make_bid` helper) + - `crates/trusted-server-core/src/auction/types.rs:445` (inline `Bid` literal) + - `crates/trusted-server-core/src/publisher.rs:2616` (`make_bid` helper) + - `crates/trusted-server-core/src/publisher.rs:2714` (inline `Bid` literal) + - `crates/trusted-server-core/src/auction/orchestrator.rs:1121,1138,1278,1325,1358` (test `Bid` literals) + - `crates/trusted-server-core/src/integrations/aps.rs:442` (production `Bid` construction) + +**Steps:** + +- [ ] **Step 1: Add three fields to `Bid` struct in `types.rs`** + + In `crates/trusted-server-core/src/auction/types.rs`, after line 200 (`pub ad_id: Option,`), add: + + ```rust + /// Prebid Cache UUID for this bid. + /// + /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. + /// Used as `hb_adid` targeting value in `window._ts.bids`. `None` for + /// non-PBS providers (e.g., APS) and PBS bids without Prebid Cache enabled. + pub cache_id: Option, + /// Prebid Cache host (e.g., `"openads.adsrvr.org"`). + /// + /// Populated from the host of `ext.prebid.cache.bids.url`. Used as + /// `hb_cache_host` targeting value. `None` when cache is absent. + pub cache_host: Option, + /// Prebid Cache path (e.g., `"/cache"`). + /// + /// Populated from the path of `ext.prebid.cache.bids.url`. Used as + /// `hb_cache_path` targeting value. `None` when cache is absent. + pub cache_path: Option, + ``` + +- [ ] **Step 2: Verify compile fails as expected** + + ```bash + cargo check --package trusted-server-core 2>&1 | grep "missing field" + ``` + + Expected: multiple errors about missing `cache_id`, `cache_host`, `cache_path` in `Bid` struct literals. This confirms every construction site will be found. + +- [ ] **Step 3: Fix `make_bid` helper in `types.rs` (line ~314)** + + Add three `None` fields to the `Bid {}` literal inside the `make_bid` test helper: + + ```rust + fn make_bid(bidder: &str) -> Bid { + Bid { + slot_id: "slot-1".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + ``` + +- [ ] **Step 4: Fix inline `Bid` literal in `types.rs` (line ~445)** + + Find the `Bid {` literal around line 445 in the test section of `types.rs`. Add: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 5: Fix `make_bid` helper in `publisher.rs` (line ~2616)** + + In the `make_bid` test helper function in `publisher.rs`, add to the `Bid {}` literal: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 6: Fix inline `Bid` literal in `publisher.rs` (line ~2714)** + + Find the `Bid {` literal around line 2714 in `publisher.rs` tests. Add: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 7: Fix five `Bid` literals in `orchestrator.rs` (lines ~1121,1138,1278,1325,1358)** + + Add to each of the five `Bid {}` literals in the test section of `orchestrator.rs`: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 8: Fix APS production `Bid` construction in `aps.rs` (line ~442)** + + In `aps.rs`, inside `parse_aps_response` (or wherever the `Ok(Bid { ... })` is around line 442), add: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + + APS does not use PBS Cache — these fields are intentionally `None` for APS bids. + +- [ ] **Step 9: Verify compile succeeds** + + ```bash + cargo check --package trusted-server-core 2>&1 | grep -E "^error" + ``` + + Expected: no output (clean compile). + +- [ ] **Step 10: Run tests to confirm nothing regressed** + + ```bash + cargo test --workspace 2>&1 | tail -5 + ``` + + Expected: all tests pass. + +- [ ] **Step 11: Run clippy and fmt** + + ```bash + cargo fmt --all + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5 + ``` + + Expected: clean. + +- [ ] **Step 12: Commit** + + ```bash + git add crates/trusted-server-core/src/auction/types.rs \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/auction/orchestrator.rs \ + crates/trusted-server-core/src/integrations/aps.rs + git commit -m "Add cache_id, cache_host, cache_path fields to Bid struct" + ``` + +--- + +## Task 2: Extract PBS Cache fields in `prebid.rs` `parse_bid` + tests + +**What:** After extracting `ad_id` in `parse_bid`, extract `ext.prebid.cache.bids.cacheId` as `cache_id` and split `ext.prebid.cache.bids.url` into `cache_host` + `cache_path`. Populate all three new fields on the returned `AuctionBid`. Add TDD tests first. + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs:1362–1391` (extraction + struct literal) +- Test: `crates/trusted-server-core/src/integrations/prebid.rs` (test module near bottom) + +**Steps:** + +- [ ] **Step 1: Write the failing tests** + + Find the `#[cfg(test)]` module in `prebid.rs`. Add these tests (they will fail because extraction doesn't exist yet): + + ```rust + #[test] + fn parse_bid_extracts_cache_id_from_ext_prebid_cache_bids() { + // Real PBS response shape from auction_response.json + let bid_json = serde_json::json!({ + "id": "bid-id-123", + "impid": "atf_sidebar_ad", + "price": 1.50, + "adm": "
ad
", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "thetradedesk") + .expect("should parse bid"); + assert_eq!( + bid.cache_id.as_deref(), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should extract cacheId as cache_id" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("openads.adsrvr.org"), + "should extract host from cache URL" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should extract path from cache URL" + ); + } + + #[test] + fn parse_bid_sets_cache_fields_to_none_when_no_cache_entry() { + let bid_json = serde_json::json!({ + "id": "bid-id-456", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250 + // no ext.prebid.cache + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert!(bid.cache_id.is_none(), "should be None when cache absent"); + assert!(bid.cache_host.is_none(), "should be None when cache absent"); + assert!(bid.cache_path.is_none(), "should be None when cache absent"); + } + + #[test] + fn parse_bid_handles_malformed_cache_url_gracefully() { + let bid_json = serde_json::json!({ + "id": "bid-id-789", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "not-a-valid-url", + "cacheId": "some-uuid" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid without panicking"); + assert_eq!( + bid.cache_id.as_deref(), + Some("some-uuid"), + "should still extract cacheId even if URL is malformed" + ); + assert!(bid.cache_host.is_none(), "should be None when URL parse fails"); + assert!(bid.cache_path.is_none(), "should be None when URL parse fails"); + } + + #[test] + fn parse_bid_preserves_ad_id_alongside_cache_id() { + let bid_json = serde_json::json!({ + "id": "bid-impression-id", + "impid": "atf_sidebar_ad", + "adid": "bidder-ad-id-abc", + "price": 1.0, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://cache.example.com/cache", + "cacheId": "cache-uuid-xyz" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert_eq!( + bid.ad_id.as_deref(), + Some("bidder-ad-id-abc"), + "should keep ad_id from adid field" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid-xyz"), + "should extract cache UUID separately" + ); + } + ``` + + Note: `base_config()` and `PrebidAuctionProvider::new()` are the standard test construction pattern used throughout the existing `prebid.rs` test module. `parse_bid` is a private method but is accessible from the `#[cfg(test)]` module in the same file. + +- [ ] **Step 2: Run tests to verify they fail** + + ```bash + cargo test --package trusted-server-core parse_bid_extracts_cache_id 2>&1 | tail -15 + ``` + + Expected: compile error (`no field 'cache_id' on type 'Bid'`) or test failure. Either confirms the extraction code is missing. + +- [ ] **Step 3: Add cache extraction to `parse_bid` in `prebid.rs`** + + In `parse_bid` (around line 1362), after the `ad_id` extraction block and before the `Ok(AuctionBid { ... })`, add: + + ```rust + // Extract PBS Cache coordinates from ext.prebid.cache.bids. + // The Prebid Universal Creative uses cacheId as hb_adid and the host/path + // to construct the fetch URL: https://?uuid= + let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + + let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + + let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + url::Url::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {e}")) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { + None + } else { + Some(path) + }; + (host, path) + }) + .unwrap_or((None, None)); + + if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{slot_id}'" + ); + } + ``` + + Then add the three fields to the `Ok(AuctionBid { ... })` struct literal (around line 1377): + + ```rust + Ok(AuctionBid { + slot_id, + price: Some(price), + currency: DEFAULT_CURRENCY.to_string(), + creative, + adomain, + bidder: seat.to_string(), + width, + height, + nurl, + burl, + ad_id, + cache_id, + cache_host, + cache_path, + metadata: std::collections::HashMap::new(), + }) + ``` + +- [ ] **Step 4: Run tests to verify they pass** + + ```bash + cargo test --package trusted-server-core parse_bid 2>&1 | tail -20 + ``` + + Expected: all 4 new tests pass. + +- [ ] **Step 5: Run full test suite** + + ```bash + cargo test --workspace 2>&1 | tail -5 + ``` + + Expected: all tests pass. + +- [ ] **Step 6: Run clippy and fmt** + + ```bash + cargo fmt --all + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5 + ``` + + Expected: clean. If clippy warns about the `log::debug!` return value being unused inside `map_err`, suppress with `let _ = ...` or restructure. + +- [ ] **Step 7: Commit** + + ```bash + git add crates/trusted-server-core/src/integrations/prebid.rs + git commit -m "Extract PBS Cache UUID and endpoint from bid ext into Bid fields" + ``` + +--- + +## Task 3: Emit cache fields in `build_bid_map` + update tests + +**What:** Change `build_bid_map` to use `bid.cache_id` for `hb_adid` (falling back to `bid.ad_id` for APS/other providers), and emit `hb_cache_host`/`hb_cache_path` when present. Update the existing `bid_map_includes_nurl_and_burl` test (which currently passes `"abc123"` as `ad_id` and asserts `hb_adid = "abc123"`) to use a cache-based bid. Add new tests covering cache fields and fallback path. + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs:1311–1342` (`build_bid_map`) +- Modify: `crates/trusted-server-core/src/publisher.rs:2608–2630` (`make_bid` helper — add cache params) +- Modify: `crates/trusted-server-core/src/publisher.rs:2666–2707` (existing `bid_map_includes_nurl_and_burl` test) +- Test: `crates/trusted-server-core/src/publisher.rs` (new tests in the existing test module) + +**Steps:** + +- [ ] **Step 1: Write new failing tests for cache field emission** + + Add these tests to the `#[cfg(test)]` module in `publisher.rs`, near the existing `bid_map_includes_nurl_and_burl` test: + + ```rust + #[test] + fn bid_map_uses_cache_id_for_hb_adid_when_present() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), + cache_host: Some("openads.adsrvr.org".to_string()), + cache_path: Some("/cache".to_string()), + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let obj = map + .get("atf_sidebar_ad") + .expect("should have entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should use cache_id for hb_adid, not ad_id" + ); + assert_eq!( + obj.get("hb_cache_host").and_then(|v| v.as_str()), + Some("openads.adsrvr.org"), + "should emit hb_cache_host" + ); + assert_eq!( + obj.get("hb_cache_path").and_then(|v| v.as_str()), + Some("/cache"), + "should emit hb_cache_path" + ); + } + + #[test] + fn bid_map_falls_back_to_ad_id_when_cache_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "aps-amazon".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("aps-bid-token".to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let obj = map + .get("atf_sidebar_ad") + .expect("should have entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("aps-bid-token"), + "should fall back to ad_id when cache_id absent" + ); + assert!( + obj.get("hb_cache_host").is_none(), + "should not emit hb_cache_host when absent" + ); + assert!( + obj.get("hb_cache_path").is_none(), + "should not emit hb_cache_path when absent" + ); + } + + #[test] + fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let obj = map + .get("atf_sidebar_ad") + .expect("should have entry") + .as_object() + .expect("should be object"); + + assert!( + obj.get("hb_adid").is_none(), + "should omit hb_adid when no cache_id and no ad_id" + ); + } + ``` + +- [ ] **Step 2: Run tests to verify they fail** + + ```bash + cargo test --package trusted-server-core bid_map_uses_cache_id 2>&1 | tail -15 + ``` + + Expected: test fails — `hb_adid` returns `"bid-impression-id"` (the wrong value) instead of the cache UUID, and `hb_cache_host`/`hb_cache_path` are not emitted. + +- [ ] **Step 3: Update `build_bid_map` in `publisher.rs`** + + Replace the current `hb_adid` emission block (lines ~1326–1331) and the `nurl`/`burl` block with: + + ```rust + // hb_adid: PBS Cache UUID when present (Prebid Universal Creative uses this + // as the cache lookup key). Falls back to ad_id for APS and other non-PBS + // providers. Note: ad_id (OpenRTB bid ID) is NOT the same as the cache UUID. + let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); + if let Some(id) = hb_adid { + obj.insert( + "hb_adid".to_string(), + serde_json::Value::String(id.to_string()), + ); + } + + // Cache endpoint coordinates — only present for PBS bids with Prebid Cache. + // The Prebid Universal Creative constructs: + // https://?uuid= + if let Some(ref host) = bid.cache_host { + obj.insert( + "hb_cache_host".to_string(), + serde_json::Value::String(host.clone()), + ); + } + if let Some(ref path) = bid.cache_path { + obj.insert( + "hb_cache_path".to_string(), + serde_json::Value::String(path.clone()), + ); + } + + 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())); + } + ``` + +- [ ] **Step 4: Update the existing `bid_map_includes_nurl_and_burl` test** + + The existing test at line ~2666 constructs a bid via `make_bid("atf_sidebar_ad", 1.50, "kargo", "abc123", ...)` and asserts `hb_adid = "abc123"`. Update `make_bid` to accept optional `cache_id`, `cache_host`, `cache_path`, OR create a separate variant. The simplest fix: update the assertion in the existing test to reflect the new priority logic. + + The test currently passes `ad_id = "abc123"` and `cache_id = None`. After the fix, `hb_adid` should still be `"abc123"` (fallback path). So the existing assertion is correct — just verify it still passes. No change needed to that test body. Just update `make_bid` to set the new fields to `None`: + + ```rust + fn make_bid( + slot_id: &str, + price: f64, + bidder: &str, + ad_id: &str, + nurl: &str, + burl: &str, + ) -> Bid { + Bid { + slot_id: slot_id.to_string(), + price: Some(price), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: Some(nurl.to_string()), + burl: Some(burl.to_string()), + ad_id: Some(ad_id.to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + } + } + ``` + + Also update the assertion comment at line ~2694 from `"should include ad_id"` to `"should fall back to ad_id when no cache_id"`. + +- [ ] **Step 5: Run all new tests** + + ```bash + cargo test --package trusted-server-core bid_map 2>&1 | tail -20 + ``` + + Expected: all `bid_map_*` tests pass, including both new and existing. + +- [ ] **Step 6: Add round-trip serialization test for `Bid`** + + Add this test to the `#[cfg(test)]` module in `types.rs`: + + ```rust + #[test] + fn bid_with_cache_fields_round_trips_through_json() { + let bid = Bid { + slot_id: "atf".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-id".to_string()), + cache_id: Some("cache-uuid".to_string()), + cache_host: Some("cache.example.com".to_string()), + cache_path: Some("/pbc/v1/cache".to_string()), + metadata: HashMap::new(), + }; + let json = serde_json::to_string(&bid).expect("should serialize Bid"); + let restored: Bid = serde_json::from_str(&json).expect("should deserialize Bid"); + assert_eq!(restored.cache_id.as_deref(), Some("cache-uuid"), "should round-trip cache_id"); + assert_eq!(restored.cache_host.as_deref(), Some("cache.example.com"), "should round-trip cache_host"); + assert_eq!(restored.cache_path.as_deref(), Some("/pbc/v1/cache"), "should round-trip cache_path"); + } + ``` + + Run: + + ```bash + cargo test --package trusted-server-core bid_with_cache_fields_round_trips 2>&1 | tail -5 + ``` + + Expected: PASS. + +- [ ] **Step 7: Run full CI suite** + + ```bash + cargo test --workspace 2>&1 | tail -5 + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5 + ``` + + Expected: all pass, no warnings. + +- [ ] **Step 8: Commit** + + ```bash + git add crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/auction/types.rs + git commit -m "Emit hb_adid from PBS Cache UUID and add hb_cache_host/hb_cache_path to bid map" + ``` + +--- + +## Final verification + +- [ ] Run `cargo test --workspace` — all pass +- [ ] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean +- [ ] Run `cargo fmt --all -- --check` — clean +- [ ] In browser devtools after deploy: `window._ts.bids` shows `hb_cache_host`, `hb_cache_path`, and `hb_adid` matching the UUID in `ext.prebid.cache.bids.cacheId` from the raw PBS response + +--- + +## Rollout reminder (from spec §8) + +1. TS: this branch deployed +2. GAM: ad ops updates Prebid line item creatives to server-side cache-fetch variant (see spec §4.6) +3. PBS: Prebid Cache already enabled (confirmed from real response) +4. Verify in devtools diff --git a/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md new file mode 100644 index 00000000..a21ec4d2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md @@ -0,0 +1,345 @@ +# Prebid Creative Rendering Fix Design + +_Author · 2026-05-29_ + +--- + +## 1. Problem Statement + +The Trusted Server server-side auction returns winning bids from PBS, but ads never +render on the Prebid path because `hb_adid` carries the wrong value. + +The Prebid Universal Creative in GAM constructs the creative fetch URL as: + +``` +https://?uuid= +``` + +TS currently sets `hb_adid` from `bid.adid` or `bid.id` (the OpenRTB bid ID / +impression ID). PBS actually caches the creative markup and returns the cache UUID +in `ext.prebid.cache.bids.cacheId`. The Universal Creative needs the **cache UUID**, +not the bid ID. The cache host and path are also not forwarded today. + +**Effect:** GAM receives a wrong UUID, fetches nothing, and the slot renders empty. + +--- + +## 2. Root Cause — Two Extraction Gaps + +### Gap 1: Wrong `hb_adid` source + +`prebid.rs` extracts: + +```rust +let ad_id = bid_obj + .get("adid") + .or_else(|| bid_obj.get("id")) // ← falls back to impression ID + .and_then(|v| v.as_str()) + .map(String::from); +``` + +Real PBS response has (in `ext.prebid.cache.bids`): + +```json +{ + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" +} +``` + +`bid.id` = `"ad-header-0-_R_4uapbsnql8alb_"` — the impression ID, useless to the +creative renderer. + +### Gap 2: Cache host and path not forwarded + +`build_bid_map` in `publisher.rs` emits `hb_pb`, `hb_bidder`, `hb_adid`, `nurl`, +`burl`. It does not emit `hb_cache_host` or `hb_cache_path`. The Prebid Universal +Creative needs both to construct the fetch URL. + +--- + +## 3. Non-Goals + +- APS creative rendering — APS does not use PBS Cache. APS creative delivery is + Amazon-owned and not addressed here. +- APS win detection over-fire — separate known limitation, separate issue. +- Dual bootstrap sync risk — separate maintenance issue. +- Slim-Prebid bundle — out of scope for Phase 1. + +--- + +## 4. Design + +### 4.1 New Fields on `Bid` (types.rs) + +Add three fields to `Bid` to carry the PBS Cache coordinates extracted from the bid +response: + +```rust +/// Prebid Cache UUID for this bid. Populated from +/// `ext.prebid.cache.bids.cacheId` in the PBS response. +/// Used as `hb_adid` targeting value in `window.tsjs.bids`. +/// None for non-PBS providers (e.g., APS) and PBS bids without cache enabled. +pub cache_id: Option, + +/// Prebid Cache host (e.g., `"openads.adsrvr.org"`). Populated from +/// the host component of `ext.prebid.cache.bids.url`. +/// Used as `hb_cache_host` targeting value. +pub cache_host: Option, + +/// Prebid Cache path (e.g., `"/cache"`). Populated from +/// the path component of `ext.prebid.cache.bids.url`. +/// Used as `hb_cache_path` targeting value. +pub cache_path: Option, +``` + +### 4.2 Extraction in `prebid.rs` + +In `parse_bid_object`, after extracting `nurl`/`burl`, extract the cache fields from +`ext.prebid.cache.bids`: + +```rust +// Extract PBS Cache coordinates from ext.prebid.cache.bids +let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + +let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + +let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + url::Url::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {}", e)) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + // path() returns "/" for root — only use if non-trivial + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { None } else { Some(path) }; + (host, path) + }) + .unwrap_or((None, None)); + +// Guard: if we extracted a cache UUID but couldn't extract the host, +// the bid will have hb_adid set but no endpoint to fetch from — creative will fail. +if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{}'", + slot_id + ); +} +``` + +Note: `url` crate is already a workspace dependency. If not, parse host/path manually +by splitting on the first `/` after the scheme. + +The `ad_id` field (from `bid.adid` / `bid.id`) is **kept** — it maps to the OpenRTB +`adid` / `id` field that APS and other non-PBS providers may use. The cache fields are +**in addition**, not replacing `ad_id`. + +Populate all three fields on `AuctionBid`: + +```rust +Ok(AuctionBid { + ..., + ad_id, + cache_id, + cache_host, + cache_path, + ... +}) +``` + +### 4.3 `build_bid_map` in `publisher.rs` + +Priority for `hb_adid`: use `cache_id` when present (PBS path), fall back to `ad_id` +(APS / other providers, backward compat): + +```rust +// hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses +// this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to +// bid.ad_id for APS and other non-PBS providers. +let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); +if let Some(id) = hb_adid { + obj.insert("hb_adid".to_string(), serde_json::Value::String(id.to_string())); +} + +// Cache coordinates — only present for PBS bids with Prebid Cache enabled +if let Some(ref host) = bid.cache_host { + obj.insert("hb_cache_host".to_string(), serde_json::Value::String(host.clone())); +} +if let Some(ref path) = bid.cache_path { + obj.insert("hb_cache_path".to_string(), serde_json::Value::String(path.clone())); +} +``` + +### 4.4 What `window.tsjs.bids` looks like after the fix + +```json +{ + "atf_sidebar_ad": { + "hb_pb": "0.01", + "hb_bidder": "thetradedesk", + "hb_adid": "f47447a0-b759-4f2f-9887-af458b79b570", + "hb_cache_host": "openads.adsrvr.org", + "hb_cache_path": "/cache", + "nurl": "https://...", + "burl": "https://..." + } +} +``` + +### 4.5 Win detection — no change required + +`slotRenderEnded` checks: + +```js +event.slot.getTargeting('hb_adid')[0] === bid.hb_adid +``` + +`adInit()` calls `setTargeting('hb_adid', cacheId)` with the cache UUID. +`event.slot.getTargeting('hb_adid')[0]` returns that same cache UUID. +`bid.hb_adid` is now also the cache UUID. +Match holds. No change to the win detection logic. + +### 4.6 GAM line item creative requirement (publisher action — not TS code) + +This is a **hard dependency outside the TS codebase**. The publisher must configure +GAM line items with a server-side compatible Prebid creative. The standard +client-side Universal Creative calls `pbjs.renderAd()` which requires Prebid.js to be +loaded — it will not be at first render (slim-Prebid loads post-`window.load`). + +The server-side compatible creative uses the `hb_cache_*` macros to fetch the markup +directly from PBS Cache: + +```html + +``` + +Alternatively, publishers using the Prebid Universal Creative package can use: + +```html + + +``` + +> **This creative configuration is a publisher/ad ops action, not a TS code change.** +> Document it in the integration guide and verify during onboarding. + +> **Cache TTL:** PBS Cache entries expire per the `bid.exp` field (default 300–3600s; +> the real response has `"exp": 3600`). Creative fetch must complete within this window. +> BFCache page restores after long idle sessions may hit expired cache entries — the +> creative will silently fail to render in that case. This is acceptable for Phase 1; +> the probability is low for typical session lengths. + +--- + +## 5. APS — Out of Scope + +APS does not use PBS Cache. APS bids will have `cache_id = None`, `cache_host = None`, +`cache_path = None`. The existing `ad_id` fallback path remains for APS. APS creative +rendering depends on Amazon's own GAM creative tag — separate from the Prebid path. + +APS win detection over-fires on the `!!bid.hb_bidder` fallback remain a known +limitation tracked separately. + +--- + +## 6. Files Changed + +| File | Change | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/auction/types.rs` | Add `cache_id`, `cache_host`, `cache_path` to `Bid` struct | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Extract `ext.prebid.cache.bids.{cacheId,url}` in `parse_bid_object`; update `AuctionBid` → `Bid` conversion to carry the three new fields | +| `crates/trusted-server-core/src/publisher.rs` | `build_bid_map`: use `cache_id` for `hb_adid`, emit `hb_cache_host`/`hb_cache_path` | + +> **Implementer note — `AuctionBid` → `Bid` conversion:** `prebid.rs` constructs an +> intermediate `AuctionBid` type that is later converted to the shared `Bid` type from +> `types.rs`. The new `cache_id`, `cache_host`, `cache_path` fields must be added to +> **both** types and the conversion must map them explicitly. Verify by grepping for +> where `AuctionBid` is constructed and where it is converted to `Bid`; if they are the +> same type (a type alias), only one struct needs the new fields. If they differ, both +> need updating or the fields will silently be `None` in `build_bid_map`. + +Test files: +| File | Change | +|---|---| +| `crates/trusted-server-core/src/integrations/prebid.rs` tests | Add test: PBS response with cache entry → correct `hb_adid`, `hb_cache_host`, `hb_cache_path` injected | +| `crates/trusted-server-core/src/publisher.rs` tests | Add test: `build_bid_map` emits cache fields when present; falls back to `ad_id` when absent | + +--- + +## 7. Testing + +**Unit tests:** + +1. `prebid.rs`: bid with `ext.prebid.cache.bids.cacheId` → `bid.cache_id = Some(uuid)`, `bid.cache_host = Some("openads.adsrvr.org")`, `bid.cache_path = Some("/cache")` +2. `prebid.rs`: bid without `ext.prebid.cache` → `bid.cache_id = None`, `bid.cache_host = None`, `bid.cache_path = None` +3. `prebid.rs`: bid with only `adid` (no cache) → `bid.ad_id = Some(...)`, `bid.cache_id = None` +4. `prebid.rs`: bid with malformed cache URL → `cache_host = None`, `cache_path = None`, no panic +5. `publisher.rs` `build_bid_map`: bid with `cache_id` → `hb_adid` uses `cache_id`, `hb_cache_host`/`hb_cache_path` emitted +6. `publisher.rs` `build_bid_map`: bid with no `cache_id` but has `ad_id` → `hb_adid` falls back to `ad_id`, no cache keys emitted +7. `publisher.rs` `build_bid_map`: APS bid (no `cache_id`, no `ad_id`) → no `hb_adid` emitted +8. `types.rs`: `Bid` with all three cache fields round-trips through `serde_json::to_string` / `from_str` + +> **Note for implementer:** `make_bid()` or equivalent `Bid` construction helpers in test modules +> must be updated to initialise `cache_id`, `cache_host`, `cache_path` to `None` +> (they will fail to compile otherwise once the fields are added to the struct). + +**Integration verification (manual):** + +After deploying, verify `window.tsjs.bids` in browser devtools shows `hb_cache_host` +and `hb_cache_path` present. Verify `hb_adid` matches the UUID in +`ext.prebid.cache.bids.cacheId` from the raw PBS response. + +--- + +## 8. Rollout Dependency Checklist + +Before this fix has end-to-end effect: + +- [ ] TS: this PR merged and deployed +- [ ] GAM: publisher ad ops updates all Prebid line item creatives to the server-side + cache-fetch variant (see §4.6) +- [ ] PBS: Prebid Cache enabled and populated (confirmed from real response — already + working) +- [ ] Verify: `window.tsjs.bids` shows correct cache UUID in `hb_adid` after deploy + +--- + +## 9. Known Remaining Gaps (not in scope) + +| Gap | Severity | Tracking | +| ----------------------------------------------------------------- | -------- | ------------------ | +| APS win detection over-fires nurl/burl | P1 | Separate issue | +| Dual bootstrap (`gpt_bootstrap.js` + `installTsAdInit`) sync risk | P2 | Separate issue | +| Slim-Prebid bundle not yet built | Phase 2 | §9.8 of design doc | diff --git a/trusted-server.toml b/trusted-server.toml index 899c8c89..c7b7ec96 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -125,6 +125,7 @@ enabled = false script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true +# slim_prebid_url = "https://cdn.example.com/tsjs-prebid.min.js" # Consent forwarding configuration # Controls how Trusted Server interprets and forwards privacy consent signals. @@ -186,7 +187,7 @@ rewrite_script = true enabled = true providers = ["prebid", "aps"] mediator = "adserver_mock" -timeout_ms = 2000 +timeout_ms = 2000 # override per-publisher via TRUSTED_SERVER__AUCTION__TIMEOUT_MS # Context keys the JS client is allowed to forward into auction requests. # Keys not in this list are silently dropped. An empty list blocks all keys. allowed_context_keys = ["permutive_segments"] @@ -195,7 +196,7 @@ allowed_context_keys = ["permutive_segments"] enabled = true pub_id = "test-pub" endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" -timeout_ms = 1000 +timeout_ms = 1000 # override per-publisher via TRUSTED_SERVER__INTEGRATIONS__APS__TIMEOUT_MS [integrations.google_tag_manager] enabled = false @@ -212,6 +213,10 @@ timeout_ms = 1000 # Inject before . # Visible in page source. Disable after investigation. # auction_html_comment = true +# +# Inject raw adm creative markup into window.tsjs.bids for GPT/GAM bridge +# debugging while PBS Cache is unavailable. NEVER enable in production. +# inject_adm_for_testing = true # Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. # Returns a plain-text response with the following fields (Fastly-observed values): # ja4 — JA4 TLS client fingerprint @@ -242,6 +247,53 @@ gam_network_id = "88059007" # drains in <50 ms but the auction runs to the limit. 500 ms is the recommended # default; raise only if your SSPs need more headroom and your analytics confirm # the DCL slip is acceptable. -auction_timeout_ms = 1500 +auction_timeout_ms = 1500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS price_granularity = "dense" +# Slot templates — override entire array via: +# TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' + +[[creative_opportunities.slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/a/b/news" +div_id = "div-ad-atf-sidebar" +page_patterns = ["/20**", "/news/**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" + +[[creative_opportunities.slot]] +id = "homepage_header_ad" +gam_unit_path = "/a/b/homepage" +div_id = "div-ad-homepage-header" +page_patterns = ["/"] +formats = [{ width = 728, height = 90 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "header" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-homepage-header" + +[[creative_opportunities.slot]] +id = "homepage_footer_ad" +gam_unit_path = "/a/b/homepage" +div_id = "div-ad-homepage-footer" +page_patterns = ["/"] +formats = [{ width = 728, height = 90 }, { width = 768, height = 66 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "btf" +zone = "fixedBottom" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-homepage-footer" From b77ebc4d1a50110066d805cb0d760d339cb62b7a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 9 Jun 2026 16:55:00 +0530 Subject: [PATCH 74/84] Wire KV-enriched EID resolution into server-side auction paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both handle_publisher_request and handle_page_bids now run the full four-step EID pipeline (resolve_client_auction_eids → resolve_auction_eids → merge_auction_eids → gate_eids_by_consent) matching the client-side /auction endpoint. Previously both paths called parse_ts_eids_cookie, which read only the ts-eids browser cookie and skipped the KV identity graph lookup entirely. AuctionDispatch gains a registry field so the partner registry reaches handle_publisher_request without exceeding the seven-argument limit. handle_page_bids gains kv and registry parameters for the same reason. parse_ts_eids_cookie is moved to #[cfg(test)] as it is now test-only. --- .../trusted-server-adapter-fastly/src/main.rs | 49 +++++++------ .../src/auction/endpoints.rs | 6 +- crates/trusted-server-core/src/cookies.rs | 10 +-- .../src/creative_opportunities.rs | 8 ++- crates/trusted-server-core/src/publisher.rs | 69 ++++++++++++++----- 5 files changed, 97 insertions(+), 45 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 6f95373a..7d8b98e4 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -328,6 +328,12 @@ async fn route_request( let path = req.get_path().to_string(); let method = req.get_method().clone(); + let registry_ref = if partner_registry.is_empty() { + None + } else { + Some(partner_registry) + }; + // Match known routes and handle them let (result, organic_route) = match (method, path.as_str()) { // Serve the tsjs library @@ -368,30 +374,32 @@ async fn route_request( } // Unified auction endpoint (returns creative HTML inline) - (Method::POST, "/auction") => { - let registry_ref = if partner_registry.is_empty() { - None - } else { - Some(partner_registry) - }; - ( - handle_auction( - settings, - orchestrator, - kv_graph.as_ref(), - registry_ref, - &ec_context, - runtime_services, - req, - ) - .await, - false, + (Method::POST, "/auction") => ( + handle_auction( + settings, + orchestrator, + kv_graph.as_ref(), + registry_ref, + &ec_context, + runtime_services, + req, ) - } + .await, + false, + ), // SPA/CSR navigation endpoint — returns slots + bids JSON for the given path (Method::GET, "/__ts/page-bids") => ( - handle_page_bids(settings, orchestrator, runtime_services, slots, req).await, + handle_page_bids( + settings, + orchestrator, + runtime_services, + kv_graph.as_ref(), + registry_ref, + slots, + req, + ) + .await, false, ), @@ -443,6 +451,7 @@ async fn route_request( trusted_server_core::publisher::AuctionDispatch { orchestrator, slots, + registry: registry_ref, }, req, ) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 84d8b3f3..f1c010de 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -208,7 +208,7 @@ pub async fn handle_auction( /// Returns `None` when any prerequisite is missing (no KV store, no partner /// store, no EC, consent denied). On KV or partner-resolution errors, logs a /// warning and returns empty EIDs so the auction can proceed in degraded mode. -fn resolve_auction_eids( +pub(crate) fn resolve_auction_eids( kv: Option<&KvIdentityGraph>, registry: Option<&PartnerRegistry>, ec_context: &EcContext, @@ -251,7 +251,7 @@ fn extract_cookie_value(req: &Request, name: &str) -> Option { None } -fn resolve_client_auction_eids( +pub(crate) fn resolve_client_auction_eids( raw: Option<&JsonValue>, cookie_value: Option<&str>, ) -> Option> { @@ -347,7 +347,7 @@ fn parse_client_auction_uid(raw: &JsonValue) -> Option { Some(Uid { id, atype, ext }) } -fn merge_auction_eids( +pub(crate) fn merge_auction_eids( client_eids: Option>, resolved_eids: Option>, ) -> Option> { diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 302e35ce..2d558e31 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -3,17 +3,18 @@ //! This module provides functionality for parsing, stripping, and forwarding cookies //! used in the trusted server system. -use base64::{engine::general_purpose::STANDARD, Engine as _}; use cookie::{Cookie, CookieJar}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::header; use http::Request; -use crate::constants::{ - COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_TS_EIDS, COOKIE_US_PRIVACY, -}; +#[cfg(test)] +use crate::constants::COOKIE_TS_EIDS; +use crate::constants::{COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_US_PRIVACY}; use crate::error::TrustedServerError; +#[cfg(test)] +use base64::{engine::general_purpose::STANDARD, Engine as _}; /// Cookie names carrying privacy consent signals. /// @@ -81,6 +82,7 @@ pub fn handle_request_cookies( /// Returns `None` if the cookie is absent, base64-malformed, JSON-malformed, /// or the decoded array is empty. Parse failures are logged at `debug` level /// so operators can diagnose JS SDK / server mismatches. +#[cfg(test)] #[must_use] pub(crate) fn parse_ts_eids_cookie(jar: Option<&CookieJar>) -> Option> { let value = jar?.get(COOKIE_TS_EIDS)?.value().to_owned(); diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 2df61bb0..a7b3d579 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -457,7 +457,8 @@ mod tests { #[test] fn to_ad_slot_injects_trusted_server_when_prebid_bidders_empty() { let mut slot = make_slot("header", vec!["/"]); - slot.targeting.insert("zone".to_string(), "header".to_string()); + slot.targeting + .insert("zone".to_string(), "header".to_string()); slot.providers.prebid = Some(PrebidSlotParams { bidders: HashMap::new(), }); @@ -515,7 +516,10 @@ mod tests { .bidders .get("mocktioneer") .expect("should have mocktioneer bidder"); - assert_eq!(params.get("custom").and_then(serde_json::Value::as_bool), Some(true)); + assert_eq!( + params.get("custom").and_then(serde_json::Value::as_bool), + Some(true) + ); } #[test] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1bcd614c..823977df 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -18,15 +18,20 @@ use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; +use crate::auction::endpoints::{ + merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, +}; use crate::auction::orchestrator::{AuctionOrchestrator, DispatchedAuction}; use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; use crate::backend::BackendConfig; use crate::compat; -use crate::constants::HEADER_X_COMPRESS_HINT; -use crate::cookies::{handle_request_cookies, parse_ts_eids_cookie}; +use crate::consent::gate_eids_by_consent; +use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; +use crate::cookies::handle_request_cookies; use crate::ec::kv::KvIdentityGraph; +use crate::ec::registry::PartnerRegistry; use crate::ec::EcContext; use crate::error::TrustedServerError; use crate::http_util::{is_navigation_request, serve_static_with_etag, RequestInfo}; @@ -810,6 +815,8 @@ pub struct AuctionDispatch<'a> { pub orchestrator: &'a crate::auction::orchestrator::AuctionOrchestrator, /// Creative opportunity slot definitions matched against the request path. pub slots: &'a [crate::creative_opportunities::CreativeOpportunitySlot], + /// Partner registry for KV-backed EID resolution. `None` skips KV enrichment. + pub registry: Option<&'a PartnerRegistry>, } /// Proxies requests to the publisher's origin server. @@ -968,7 +975,19 @@ pub async fn handle_publisher_request( &request_info, req.get_header_str("user-agent"), ); - auction_request.user.eids = parse_ts_eids_cookie(cookie_jar.as_ref()); + let ts_eids_value = cookie_jar + .as_ref() + .and_then(|j| j.get(COOKIE_TS_EIDS)) + .map(|c| c.value().to_owned()); + let client_eids = resolve_client_auction_eids(None, ts_eids_value.as_deref()); + let kv_eids = resolve_auction_eids(kv, auction.registry, ec_context); + let merged_eids = merge_auction_eids(client_eids, kv_eids); + let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); + auction_request.user.eids = + gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + if had_eids && auction_request.user.eids.is_none() { + log::warn!("Server-side auction EIDs stripped by TCF consent gating"); + } let client_ip = services.client_info.client_ip.map(|ip| ip.to_string()); if client_ip.is_some() || geo.is_some() { let device = auction_request.device.get_or_insert(DeviceInfo { @@ -1456,6 +1475,8 @@ pub async fn handle_page_bids( settings: &Settings, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, + kv: Option<&KvIdentityGraph>, + registry: Option<&PartnerRegistry>, slots: &[crate::creative_opportunities::CreativeOpportunitySlot], req: Request, ) -> Result> { @@ -1527,7 +1548,19 @@ pub async fn handle_page_bids( &request_info, req.get_header_str("user-agent"), ); - auction_request.user.eids = parse_ts_eids_cookie(cookie_jar.as_ref()); + let ts_eids_value = cookie_jar + .as_ref() + .and_then(|j| j.get(COOKIE_TS_EIDS)) + .map(|c| c.value().to_owned()); + let client_eids = resolve_client_auction_eids(None, ts_eids_value.as_deref()); + let kv_eids = resolve_auction_eids(kv, registry, &ec_ctx); + let merged_eids = merge_auction_eids(client_eids, kv_eids); + let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); + auction_request.user.eids = + gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + if had_eids && auction_request.user.eids.is_none() { + log::warn!("Page-bids auction EIDs stripped by TCF consent gating"); + } let client_ip = services.client_info.client_ip.map(|ip| ip.to_string()); if client_ip.is_some() || geo.is_some() { let device = auction_request.device.get_or_insert(DeviceInfo { @@ -3133,9 +3166,10 @@ mod tests { let services = noop_services(); let req = make_page_bids_request("/2024/01/my-article/"); - let response = handle_page_bids(&settings, &orchestrator, &services, &[], req) - .await - .expect("should return ok response"); + let response = + handle_page_bids(&settings, &orchestrator, &services, None, None, &[], req) + .await + .expect("should return ok response"); let body: serde_json::Value = serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); @@ -3170,9 +3204,10 @@ mod tests { let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("user-agent", "Mozilla/5.0 (compatible; Googlebot/2.1)"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) - .await - .expect("should return ok response"); + let response = + handle_page_bids(&settings, &orchestrator, &services, None, None, &slots, req) + .await + .expect("should return ok response"); let body: serde_json::Value = serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); @@ -3206,9 +3241,10 @@ mod tests { let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("sec-purpose", "prefetch"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) - .await - .expect("should return ok response"); + let response = + handle_page_bids(&settings, &orchestrator, &services, None, None, &slots, req) + .await + .expect("should return ok response"); let body: serde_json::Value = serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); @@ -3240,9 +3276,10 @@ mod tests { let slots = article_slot(); // slot matches /20** only let req = make_page_bids_request("/about"); // does not match - let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) - .await - .expect("should return ok response"); + let response = + handle_page_bids(&settings, &orchestrator, &services, None, None, &slots, req) + .await + .expect("should return ok response"); let body: serde_json::Value = serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); From 321fbafb3dc1123bba292ca5a125423d1c14b77c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 9 Jun 2026 16:58:24 +0530 Subject: [PATCH 75/84] Remove dead build.rs from trusted-server-adapter-fastly The file only emitted a rerun-if-changed watch for creative-opportunities.toml, which was deleted when slot config was consolidated into trusted-server.toml. Config validation now runs entirely in trusted-server-core/build.rs. --- crates/trusted-server-adapter-fastly/build.rs | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 crates/trusted-server-adapter-fastly/build.rs diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs deleted file mode 100644 index 0ad1f2dd..00000000 --- a/crates/trusted-server-adapter-fastly/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("cargo:rerun-if-changed=../../../creative-opportunities.toml"); -} From 459fe60179a89ca8057929f77a44d3cef17755b8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 9 Jun 2026 17:04:19 +0530 Subject: [PATCH 76/84] Fix CI failures: update integration-tests lock file and prefer-const lint error --- crates/integration-tests/Cargo.lock | 237 +++++++++++--------- crates/js/lib/src/integrations/gpt/index.ts | 2 +- 2 files changed, 127 insertions(+), 112 deletions(-) diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index 9f80a0ef..40fbe003 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -201,9 +201,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -274,9 +274,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -316,7 +316,7 @@ checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" dependencies = [ "async-stream", "base64", - "bitflags 2.11.1", + "bitflags 2.13.0", "bollard-buildkit-proto", "bollard-stubs", "bytes", @@ -387,9 +387,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -398,9 +398,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -423,9 +423,9 @@ checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -441,9 +441,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -481,9 +481,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -530,9 +530,9 @@ checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "config" -version = "0.15.22" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -903,9 +903,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -923,9 +923,9 @@ dependencies = [ [[package]] name = "docker_credential" -version = "1.3.3" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ "base64", "serde", @@ -1043,9 +1043,9 @@ checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -1469,6 +1469,12 @@ dependencies = [ "wasip3", ] +[[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" @@ -1520,6 +1526,15 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1533,11 +1548,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1584,9 +1599,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1629,9 +1644,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2005,9 +2020,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -2018,9 +2033,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -2065,13 +2080,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2142,9 +2156,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lol_html" @@ -2152,7 +2166,7 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cssparser 0.36.0", "encoding_rs", @@ -2210,9 +2224,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -2232,9 +2246,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2324,9 +2338,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2400,11 +2414,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -2431,9 +2445,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2840,9 +2854,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -2850,9 +2864,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", @@ -2863,9 +2877,9 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -2972,7 +2986,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3088,7 +3102,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -3147,7 +3161,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3171,9 +3185,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3305,7 +3319,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3328,7 +3342,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser 0.34.0", "derive_more 0.99.20", "fxhash", @@ -3347,7 +3361,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser 0.36.0", "derive_more 2.1.1", "log", @@ -3410,9 +3424,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3455,9 +3469,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -3475,9 +3489,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3520,9 +3534,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" @@ -3560,9 +3574,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3710,7 +3724,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4053,11 +4067,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -4131,6 +4145,7 @@ dependencies = [ "fastly", "flate2", "futures", + "glob", "hex", "hmac", "http", @@ -4189,9 +4204,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -4217,9 +4232,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4321,9 +4336,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4417,9 +4432,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -4430,9 +4445,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ "js-sys", "wasm-bindgen", @@ -4440,9 +4455,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4450,9 +4465,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -4463,9 +4478,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -4498,7 +4513,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -4506,9 +4521,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", @@ -4526,9 +4541,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] @@ -4740,7 +4755,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -4758,7 +4773,7 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -4810,7 +4825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -4858,9 +4873,9 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", @@ -4869,9 +4884,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4892,18 +4907,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 4276bc7b..1d11571b 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -203,7 +203,7 @@ function injectAdmIntoSlot(divId: string, adm: string): void { try { // divId may be the container div (used by GPT slot) or the inner div. // Search both so we can find the GAM iframe wherever it was rendered. - let slotEl = document.getElementById(divId); + const slotEl = document.getElementById(divId); if (!slotEl) return; // Extract the first iframe src from the adm (e.g. mocktioneer creative From 66140220c932efe55cc15da4c1f88fef42dc6626 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 9 Jun 2026 17:09:01 +0530 Subject: [PATCH 77/84] Update workspace Cargo.lock to resolve shared dependency version mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns log (0.4.29 → 0.4.32) and serde_json (1.0.149 → 1.0.150) with the versions already pulled into crates/integration-tests/Cargo.lock. --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fd679f6..2d1ad743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1582,9 +1582,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "log-fastly" @@ -2270,9 +2270,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", From e6f5fc890ff7875b3c8c19ed88c440b95b48b38b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 9 Jun 2026 17:33:59 +0530 Subject: [PATCH 78/84] Update spec to reflect consolidated slot config and current global namespace Replace all references to the deleted `creative-opportunities.toml` file with the `[creative_opportunities]` section in `trusted-server.toml`. Update all `window.__ts_*` global name references to the current `window.tsjs.*` namespace (tsjs.bids, tsjs.adSlots, tsjs.adInit). --- ...6-04-15-server-side-ad-templates-design.md | 144 +++++++++--------- 1 file changed, 73 insertions(+), 71 deletions(-) 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 index 94fe1999..bdf24ff9 100644 --- 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 @@ -89,13 +89,14 @@ across every navigation in the user's clickstream rather than once per session. ## 4. Architecture -### 4.1 New File: `creative-opportunities.toml` +### 4.1 Slot configuration in `trusted-server.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, GAM targeting -key-values, and per-provider bidder params. PBS bidder-level params (placement IDs, -account IDs) live in Prebid Server stored requests, keyed by slot ID. APS params are -specified inline per slot under `[slot.providers.aps]`. +Slot templates live in `trusted-server.toml` under `[[creative_opportunities.slot]]` +(consolidated from the original `creative-opportunities.toml`). Each entry holds page +pattern matching rules, ad formats, floor prices, GAM targeting key-values, and +per-provider bidder params. PBS bidder-level params (placement IDs, account IDs) live +in Prebid Server stored requests, keyed by slot ID. APS params are specified inline per +slot under `[slot.providers.aps]`. Loaded at build time via `include_str!()` and compiled into the WASM binary. Slot changes require a redeploy; this is intentional (fast reads, no KV overhead, no @@ -103,7 +104,7 @@ per-request cost). A migration path to KV-backed config is tracked in §9.5. `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`. +bid below the floor is discarded at the orchestrator level before it enters `tsjs.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. @@ -118,7 +119,7 @@ gam_network_id = "21765378893" # Optional. Defaults to [auction].timeout_ms if not set. # Recommended: 500ms (vs client-side 1000–1500ms) due to lower edge→PBS RTT. # This value is also the upper bound on the -close hold; once A_deadline -# fires, TS injects an empty __ts_bids and emits regardless. +# fires, TS injects an empty tsjs.bids and emits regardless. auction_timeout_ms = 500 # Granularity table for hb_pb price bucket strings. @@ -127,7 +128,7 @@ auction_timeout_ms = 500 price_granularity = "dense" ``` -#### `creative-opportunities.toml` schema +#### `[creative_opportunities]` schema ```toml [[slot]] @@ -278,10 +279,10 @@ request. Before firing, TS gates on: skip the auction. Avoids spending auction inventory on speculative navigations that may never paint. - **Method** — only `GET` requests trigger auctions. `HEAD` requests skip. -- **Slot match** — at least one slot in `creative-opportunities.toml` must match the +- **Slot match** — at least one slot in `[creative_opportunities]` (in `trusted-server.toml`) must match the request path. Empty match = no auction. -Skipped auctions emit no `__ts_bids` and let the page proceed unmodified by the ad +Skipped auctions emit no `tsjs.bids` and let the page proceed unmodified by the ad stack. Skipped requests still benefit from the EC cookie set / KV identity update paths that run independently of the auction. @@ -294,7 +295,7 @@ existing EC pipeline and is the load-bearing identity input to the auction (see Consent gating: - If consent is **absent or denied** (no TCF consent string, or purpose 1 not consented): - the auction is not fired. `__ts_bids` is omitted from the page. GPT falls back to its + the auction is not fired. `tsjs.bids` is omitted from the page. GPT falls back to its own auction. This is treated as a first-class edge case in §8. - **Mid-page consent revocation** is out of scope for Phase 1; bids already injected remain. Phase 2 will address consent event propagation. @@ -313,8 +314,7 @@ The orchestrator's existing behavior is unchanged: (`creative_opportunities.auction_timeout_ms`, falling back to `[auction].timeout_ms`) - Floor price filtering, bid unification, and winning bid selection are applied as today - PBS resolves bidder params from its stored requests by slot ID -- APS bidder params are read from `[slot.providers.aps]` in - `creative-opportunities.toml` +- APS bidder params are read from `[slot.providers.aps]` in `trusted-server.toml` #### The bounded `` hold @@ -344,7 +344,7 @@ In English: finished by the time we need it because we waited for origin too. - If origin drains before the auction completes: body close held until either auction completes or `A_deadline` fires. Hold is bounded by `A_deadline`. -- If `A_deadline` fires first: TS injects `__ts_bids = {}` (graceful no-bid fallback) +- If `A_deadline` fires first: TS injects `tsjs.bids = {}` (graceful no-bid fallback) and emits the close tag. GPT proceeds without bid targeting; GAM runs its own auction. This is the **soft inner deadline watchdog** — auction overrun never blocks the page past `A_deadline`. @@ -371,12 +371,12 @@ and resource load time, exactly the same as a page without TS in the path. TS injects two `, ContentType::Html)`. @@ -446,7 +446,7 @@ task, fallback to `{}` on watchdog) and calls > U+2029 are unicode-escaped to neutralize any markup that could break out of the > `", + b"", + ], + Arc::clone(&read_count), + ); + let mut processor = RecordingProcessor { + read_count: Arc::clone(&read_count), + body_close_processed_at: Arc::clone(&body_close_processed_at), + }; + let ad_bids_state = Arc::new(Mutex::new(None)); + let ctx = AuctionCollectCtx { + dispatched, + price_granularity: PriceGranularity::default(), + ad_bids_state: &ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + }; + let mut output = Vec::new(); + + body_close_hold_loop(reader, &mut output, &mut processor, ctx) + .await + .expect("should stream body with auction hold"); + + assert_eq!( + body_close_processed_at.load(Ordering::SeqCst), + 1, + "close-body tail should be processed as soon as it is found, before later chunks are read" + ); + assert_eq!( + std::str::from_utf8(&output).expect("should be utf8"), + "painted", + "post-body chunks should still stream in order" + ); + } + #[test] fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { let mut hold = BodyCloseHoldBuffer::new(); diff --git a/trusted-server.toml b/trusted-server.toml index 73f225a1..e1c35e11 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -215,7 +215,7 @@ rewrite_script = true [auction] enabled = true providers = ["prebid", "aps"] -mediator = "adserver_mock" +# mediator = "adserver_mock" timeout_ms = 2000 # override per-publisher via TRUSTED_SERVER__AUCTION__TIMEOUT_MS # Context keys the JS client is allowed to forward into auction requests. # Keys not in this list are silently dropped. An empty list blocks all keys. From def951ab8f2b3c560a1694a570ca48b133738ab7 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 10 Jun 2026 16:25:56 +0530 Subject: [PATCH 83/84] Add per-bidder Prebid nurl suppression and refresh metadata --- .env.example | 1 + .../js/lib/src/integrations/prebid/index.ts | 118 +++++++++++++++--- .../test/integrations/prebid/index.test.ts | 70 +++++++++++ .../src/integrations/prebid.rs | 66 +++++++++- docs/guide/configuration.md | 2 + docs/guide/integrations/prebid.md | 2 + ...6-04-15-server-side-ad-templates-design.md | 11 +- trusted-server.toml | 2 + 8 files changed, 247 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 1121ecd9..cec5d91d 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_abc"}}}' # Preferred canonical env shape for future generic rules # TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_abc"}}]' +# TRUSTED_SERVER__INTEGRATIONS__PREBID__SUPPRESS_NURL_BIDDERS=exampleBidder,anotherBidder # TRUSTED_SERVER__INTEGRATIONS__PREBID__AUTO_CONFIGURE=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index c395905e..feb31e1a 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -32,6 +32,7 @@ import './_user_ids.generated'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; +import type { AuctionSlot } from '../../core/types'; import { DEFAULT_PREBID_USER_ID_MODULES, PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; @@ -212,7 +213,13 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: type PbjsConfig = Parameters[0]; type TrustedServerBid = { bidder?: string; params?: Record }; -type TrustedServerAdUnit = { code?: string; bids?: TrustedServerBid[] }; +type BannerSize = [number, number]; +type TrustedServerBanner = { sizes: BannerSize[]; name?: string }; +type TrustedServerAdUnit = { + code?: string; + mediaTypes?: { banner?: TrustedServerBanner }; + bids?: TrustedServerBid[]; +}; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -232,6 +239,17 @@ type PrebidUserIdEid = { uids?: Array<{ id?: unknown; atype?: unknown; ext?: unknown }>; }; +type RefreshGptSlot = { + getSlotElementId?: () => string; + getTargeting?: (key: string) => string[]; + getSizes?: () => unknown[]; +}; + +const DEFAULT_REFRESH_SIZES: BannerSize[] = [ + [728, 90], + [300, 250], +]; + function sanitizeAuctionUid(uid: { id?: unknown; atype?: unknown; @@ -258,6 +276,63 @@ function isDefined(value: T | undefined): value is T { return value !== undefined; } +function isPositiveFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function parseBannerSize(size: unknown): BannerSize | undefined { + if (Array.isArray(size) && isPositiveFiniteNumber(size[0]) && isPositiveFiniteNumber(size[1])) { + return [size[0], size[1]]; + } + + const gptSize = size as { getWidth?: () => unknown; getHeight?: () => unknown }; + const width = gptSize?.getWidth?.(); + const height = gptSize?.getHeight?.(); + if (isPositiveFiniteNumber(width) && isPositiveFiniteNumber(height)) { + return [width, height]; + } + + return undefined; +} + +function bannerSizesFromGptSlot(slot: RefreshGptSlot): BannerSize[] | undefined { + const sizes = slot.getSizes?.(); + if (!Array.isArray(sizes)) { + return undefined; + } + + const parsedSizes = sizes.map(parseBannerSize).filter(isDefined); + return parsedSizes.length > 0 ? parsedSizes : undefined; +} + +function bannerSizesFromInjectedSlot(slot: AuctionSlot | undefined): BannerSize[] | undefined { + const parsedSizes = slot?.formats?.map(parseBannerSize).filter(isDefined) ?? []; + return parsedSizes.length > 0 ? parsedSizes : undefined; +} + +function refreshSlotElementId(slot: RefreshGptSlot): string | undefined { + const elementId = slot.getSlotElementId?.(); + return elementId && elementId.length > 0 ? elementId : undefined; +} + +function findInjectedSlotForRefresh(slot: RefreshGptSlot): AuctionSlot | undefined { + const elementId = refreshSlotElementId(slot); + if (!elementId) { + return undefined; + } + + return window.tsjs?.adSlots?.find( + (adSlot) => + elementId === adSlot.div_id || + elementId === `${adSlot.div_id}-container` || + elementId.startsWith(adSlot.div_id) + ); +} + +function firstTargetingValue(values: string[] | undefined): string | undefined { + return values?.find((value) => value.length > 0); +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -524,13 +599,15 @@ export function installRefreshHandler(timeoutMs = 1500): void { pubads.refresh = function (slots?: unknown[], opts?: unknown) { // For bare refresh() calls (no slots arg), get all registered slots from GPT // so we can filter out TS first-impression slots and auction the rest. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const targetSlots: any[] = slots ?? (pubads as any).getSlots?.() ?? []; + const targetSlots = ( + slots ?? + (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? + [] + ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); // Filter out TS first-impression slots — they don't need client-side refresh auctions. const nonTsSlots = targetSlots.filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (s: any) => !s.getTargeting?.('ts_initial')?.includes('1') + (slot) => !slot.getTargeting?.('ts_initial')?.includes('1') ); if (!nonTsSlots.length) { @@ -538,19 +615,24 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const adUnits = nonTsSlots.map((s: any) => ({ - code: s.getSlotElementId?.() ?? s, - mediaTypes: { - banner: { - sizes: [ - [728, 90], - [300, 250], - ] as [number, number][], - }, - }, - bids: [{ bidder: ADAPTER_CODE, params: { zone: 'refresh' } }], - })); + const adUnits = nonTsSlots.map((slot) => { + const injectedSlot = findInjectedSlotForRefresh(slot); + const zone = + injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); + const banner: TrustedServerBanner = { + sizes: + bannerSizesFromInjectedSlot(injectedSlot) ?? + bannerSizesFromGptSlot(slot) ?? + DEFAULT_REFRESH_SIZES, + ...(zone ? { name: zone } : {}), + }; + + return { + code: refreshSlotElementId(slot) ?? 'refresh-slot', + mediaTypes: { banner }, + bids: [{ bidder: ADAPTER_CODE, params: zone ? { [ZONE_KEY]: zone } : {} }], + }; + }); pbjs.requestBids({ adUnits, diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index f12345d2..c79bfd08 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -62,6 +62,7 @@ import { getInjectedConfig, auctionBidsToPrebidBids, installPrebidNpm, + installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; @@ -765,6 +766,75 @@ describe('prebid/installPrebidNpm with server-injected config', () => { }); }); +describe('prebid/installRefreshHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + afterEach(() => { + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + it('builds refresh ad units from injected slot metadata', () => { + const originalRefresh = vi.fn(); + const gptSlot = { + getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), + getTargeting: vi.fn(() => []), + }; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => [gptSlot]), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).tsjs = { + adSlots: [ + { + id: 'homepage_header_ad', + gam_unit_path: '/123/homepage', + div_id: 'div-ad-homepage-header', + formats: [ + [970, 250], + [728, 90], + ], + targeting: { zone: 'homepage', pos: 'atf' }, + }, + ], + }; + + installRefreshHandler(750); + pubads.refresh(); + + expect(mockRequestBids).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 750, + adUnits: [ + expect.objectContaining({ + code: 'div-ad-homepage-header', + mediaTypes: { + banner: { + name: 'homepage', + sizes: [ + [970, 250], + [728, 90], + ], + }, + }, + bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], + }), + ], + }) + ); + }); +}); + describe('prebid/client-side bidders', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 6c0ee0ea..b757a1bf 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -189,6 +189,14 @@ pub struct PrebidIntegrationConfig { /// client does not double-fire them via `sendBeacon`. Default: `false`. #[serde(default)] pub suppress_nurl: bool, + /// Bidder seats whose `nurl` and `burl` should be stripped before they reach + /// `window.tsjs.bids`. + /// + /// Use this when only specific PBS seats fire win/billing notifications + /// internally. The global [`suppress_nurl`](Self::suppress_nurl) switch still + /// suppresses every bidder when set. + #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] + pub suppress_nurl_bidders: Vec, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -1341,6 +1349,15 @@ impl PrebidAuctionProvider { } } + fn should_suppress_bid_notifications(&self, bidder: &str) -> bool { + self.config.suppress_nurl + || self + .config + .suppress_nurl_bidders + .iter() + .any(|suppressed_bidder| suppressed_bidder == bidder) + } + /// Parse a single bid from `OpenRTB` response. fn parse_bid(&self, bid_obj: &Json, seat: &str) -> Result { let slot_id = bid_obj @@ -1370,7 +1387,8 @@ impl PrebidAuctionProvider { .and_then(|v| u32::try_from(v).ok()) .unwrap_or(0); - let nurl = if self.config.suppress_nurl { + let suppress_bid_notifications = self.should_suppress_bid_notifications(seat); + let nurl = if suppress_bid_notifications { None } else { bid_obj @@ -1379,7 +1397,7 @@ impl PrebidAuctionProvider { .map(std::string::ToString::to_string) }; - let burl = if self.config.suppress_nurl { + let burl = if suppress_bid_notifications { None } else { bid_obj @@ -1761,6 +1779,7 @@ mod tests { bid_param_override_rules: Vec::new(), consent_forwarding: ConsentForwardingMode::Both, suppress_nurl: false, + suppress_nurl_bidders: Vec::new(), } } @@ -4844,6 +4863,49 @@ set = { networkId = 42 } ); } + #[test] + fn parse_bid_strips_nurl_and_burl_for_configured_suppressed_bidder_only() { + let bid_json = serde_json::json!({ + "impid": "atf_sidebar_ad", + "price": 1.50, + "w": 300, + "h": 250, + "nurl": "https://ssp.example/win?id=abc123", + "burl": "https://ssp.example/bill?id=abc123" + }); + let config = PrebidIntegrationConfig { + suppress_nurl_bidders: vec!["appnexus".to_string()], + ..base_config() + }; + let provider = PrebidAuctionProvider::new(config); + + let suppressed_bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse suppressed bidder bid"); + let preserved_bid = provider + .parse_bid(&bid_json, "openx") + .expect("should parse unsuppressed bidder bid"); + + assert_eq!( + suppressed_bid.nurl, None, + "should strip nurl only for the configured bidder" + ); + assert_eq!( + suppressed_bid.burl, None, + "should strip burl only for the configured bidder" + ); + assert_eq!( + preserved_bid.nurl.as_deref(), + Some("https://ssp.example/win?id=abc123"), + "should preserve nurl for bidders not configured for suppression" + ); + assert_eq!( + preserved_bid.burl.as_deref(), + Some("https://ssp.example/bill?id=abc123"), + "should preserve burl for bidders not configured for suppression" + ); + } + #[test] fn parse_bid_preserves_ad_id_alongside_cache_id() { let bid_json = serde_json::json!({ diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 9863de1c..a7d93d1a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -726,6 +726,8 @@ apply when the integration section exists in `trusted-server.toml`. | `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | | `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | | `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | +| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | +| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | | `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | | `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | | `debug_query_params` | String | `None` | Extra query params appended for debugging | diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 42e0d6b7..1e887005 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -58,6 +58,8 @@ set = { placementId = "_s2sHeaderPlacement" } | `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | | `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | | `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | +| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | +| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | | `debug` | Boolean | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`; surfaces debug metadata in auction responses) | | `test_mode` | Boolean | `false` | Set the OpenRTB `test: 1` flag so bidders treat the auction as non-billable test traffic. Separate from `debug` to avoid suppressing real demand | | `debug_query_params` | String | `None` | Extra query params appended for debugging | 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 index bdf24ff9..8617ef87 100644 --- 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 @@ -487,10 +487,11 @@ The `hb_adid` match confirms two things: that the slot was filled (`!event.isEmp **and** that **our** Prebid bid (not a direct deal or backfill) won the GAM line item match. Only then are SSP win/billing pixels fired. -**Per-bidder suppression** (`[integrations.].suppress_nurl`, default `false`) -is retained as an escape hatch in case a specific PBS deployment fires `nurl` -internally and wants to avoid double-firing. APS `burl` follows the same client-side -path. +**Per-bidder suppression** (`[integrations.prebid].suppress_nurl_bidders`, default +`[]`) is retained as an escape hatch in case a specific PBS seat fires `nurl` +internally and wants to avoid double-firing. `[integrations.prebid].suppress_nurl = +true` remains a deployment-wide compatibility switch. APS `burl` follows the same +client-side path. > **Operational note:** Client-side firing introduces a small (~50–200ms) delay in > win-pixel arrival vs server-side firing. SSPs accept this — it's identical to @@ -1047,7 +1048,7 @@ saving. synchronous bid read, `slotRenderEnded` nurl + burl firing, `ts_initial` sentinel; add lazy slim-Prebid loader scheduled for post-`window.load` - **`crates/trusted-server-core/src/integrations/prebid.rs`** — add - `suppress_nurl` per-bidder config (default `false`); **no server-side nurl firing + `suppress_nurl_bidders` per-bidder config (default `[]`); **no server-side nurl firing in the page-load path** (firing is client-side from `slotRenderEnded`) - **`trusted-server.toml`** — add `[creative_opportunities]` section - **`crates/trusted-server-core/src/settings.rs`** — add `CreativeOpportunitiesConfig` diff --git a/trusted-server.toml b/trusted-server.toml index e1c35e11..efff7469 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -95,6 +95,8 @@ client_side_bidders = [] # Set to true if PBS is configured to fire win/billing notifications server-side # (ext.prebid.events.enabled), to prevent the client from double-firing nurl/burl. # suppress_nurl = false +# For per-bidder suppression, list PBS seats that fire win/billing internally. +# suppress_nurl_bidders = ["exampleBidder"] [integrations.nextjs] enabled = false From 80fe39220cbd2eed458ca3471331b183b51bd186 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 10 Jun 2026 19:19:30 +0530 Subject: [PATCH 84/84] Resolve server-side ad template review issues --- .env.example | 2 +- crates/js/lib/src/core/types.ts | 2 + crates/js/lib/src/integrations/gpt/index.ts | 39 +++++- .../js/lib/src/integrations/prebid/index.ts | 39 ++++-- .../lib/test/integrations/gpt/index.test.ts | 97 ++++++++++++++ .../test/integrations/prebid/index.test.ts | 80 ++++++++++++ crates/trusted-server-core/src/publisher.rs | 123 ++++++++++++++++-- docs/guide/auction-orchestration.md | 20 +-- docs/guide/configuration.md | 6 +- trusted-server.toml | 69 ++-------- 10 files changed, 374 insertions(+), 103 deletions(-) diff --git a/.env.example b/.env.example index cec5d91d..c2ac88e3 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,7 @@ TRUSTED_SERVER__REQUEST_SIGNING__ENABLED=false # Prebid TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.com/openrtb2/auction +# TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.example.com/openrtb2/auction # TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 # TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus # TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 31f66d0a..57a14f3e 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -98,6 +98,8 @@ export interface TsjsApi { servicesEnabled?: boolean; /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ divToSlotId?: Record; + /** Slot-level GPT targeting keys TS applied on the previous route. */ + prevSlotTargetingKeys?: Record; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; } diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index b7a81bc9..21f1f120 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -25,6 +25,14 @@ import { installGptGuard } from './script_guard'; */ const TS_INITIAL_TARGETING_KEY = 'ts_initial' as const; +const TS_BID_TARGETING_KEYS = [ + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', +] as const; +const TS_BASE_TARGETING_KEYS = [...TS_BID_TARGETING_KEYS, TS_INITIAL_TARGETING_KEY] as const; // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) @@ -34,6 +42,7 @@ interface GoogleTagSlot { getAdUnitPath(): string; getSlotElementId(): string; setTargeting(key: string, value: string | string[]): GoogleTagSlot; + clearTargeting?(key?: string): GoogleTagSlot; addService(service: GoogleTagPubAdsService): GoogleTagSlot; getTargeting?(key: string): string[]; } @@ -82,6 +91,14 @@ function messageSourceBelongsToConfiguredSlot(source: MessageEventSource | null) ); } +function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { + if (typeof slot.clearTargeting !== 'function') return; + + for (const key of new Set(keys)) { + slot.clearTargeting(key); + } +} + interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; @@ -333,6 +350,8 @@ export function installTsAdInit(): void { // All slots to refresh (TS-defined + publisher-owned reused). const slotsToRefresh: GoogleTagSlot[] = []; const divToSlotId: Record = {}; + const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {}; + const nextSlotTargetingKeys: Record = {}; slots.forEach((slot) => { // Resolve actual div ID: exact match first, then prefix query. @@ -363,19 +382,26 @@ export function installTsAdInit(): void { tsOwned = true; } + const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; + clearTargetingKeys(gptSlot, [ + ...TS_BASE_TARGETING_KEYS, + ...(prevSlotTargetingKeys[actualDivId] ?? []), + ...(prevSlotTargetingKeys[slotDivId2] ?? []), + ]); + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); - (['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'] as const).forEach( - (key) => { - if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); - } - ); + TS_BID_TARGETING_KEYS.forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); + }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); // Map both inner div and container div → slot ID so slotRenderEnded // (which reports the GPT slot's div, i.e. slotDivId/container) can look up // the slot, while adm injection (which targets the inner div) also works. divToSlotId[actualDivId] = slot.id; - const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; if (slotDivId2 !== actualDivId) divToSlotId[slotDivId2] = slot.id; + const slotTargetingKeys = Object.keys(slot.targeting ?? {}); + nextSlotTargetingKeys[actualDivId] = slotTargetingKeys; + if (slotDivId2 !== actualDivId) nextSlotTargetingKeys[slotDivId2] = slotTargetingKeys; if (tsOwned) newSlots.push(gptSlot); slotsToRefresh.push(gptSlot); @@ -391,6 +417,7 @@ export function installTsAdInit(): void { ts.prevGptSlots = newSlots as unknown[]; // Replace (not merge) so destroyed slots from previous navigation don't linger. ts.divToSlotId = divToSlotId; + ts.prevSlotTargetingKeys = nextSlotTargetingKeys; // enableSingleRequest and enableServices must only be called once per page load. if (!ts.servicesEnabled) { diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index feb31e1a..faa6ec04 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -39,6 +39,14 @@ import { DEFAULT_PREBID_USER_ID_MODULES, PREBID_USER_ID_MODULE_REGISTRY } from ' const ADAPTER_CODE = 'trustedServer'; const BIDDER_PARAMS_KEY = 'bidderParams'; const ZONE_KEY = 'zone'; +const TS_REFRESH_TARGETING_KEYS = [ + 'ts_initial', + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', +] as const; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -242,6 +250,7 @@ type PrebidUserIdEid = { type RefreshGptSlot = { getSlotElementId?: () => string; getTargeting?: (key: string) => string[]; + clearTargeting?: (key?: string) => RefreshGptSlot; getSizes?: () => unknown[]; }; @@ -333,6 +342,14 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { return values?.find((value) => value.length > 0); } +function clearRefreshTargeting(slot: RefreshGptSlot): void { + if (typeof slot.clearTargeting !== 'function') return; + + for (const key of TS_REFRESH_TARGETING_KEYS) { + slot.clearTargeting(key); + } +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -569,8 +586,9 @@ export function installPrebidNpm(config?: Partial): typeof pbjs * Wraps `googletag.pubads().refresh()` so that when the publisher's GPT * refresh policy fires (sticky anchor, viewability dwell, infinite scroll), * Prebid runs a fresh client-side auction for the refreshing slots before - * the GAM call. TS-owned first-impression slots (`ts_initial=1`) are excluded - * — they are managed server-side and should not re-auction client-side. + * the GAM call. TS-owned first-impression slots (`ts_initial=1`) are included + * on later publisher refreshes, but stale TS server-side targeting is cleared + * before fresh Prebid targeting is applied. * * Must be called after `installPrebidNpm()` and after GPT is loaded. * Idempotent: safe to call multiple times — wraps only once via a sentinel. @@ -598,24 +616,20 @@ export function installRefreshHandler(timeoutMs = 1500): void { const originalRefresh = pubads.refresh.bind(pubads); pubads.refresh = function (slots?: unknown[], opts?: unknown) { // For bare refresh() calls (no slots arg), get all registered slots from GPT - // so we can filter out TS first-impression slots and auction the rest. + // so we can auction the same concrete slot list and avoid stale targeting. const targetSlots = ( slots ?? (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? [] ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); - // Filter out TS first-impression slots — they don't need client-side refresh auctions. - const nonTsSlots = targetSlots.filter( - (slot) => !slot.getTargeting?.('ts_initial')?.includes('1') - ); - - if (!nonTsSlots.length) { - // All slots are TS-owned — pass through unchanged. + if (!targetSlots.length) { return originalRefresh(slots, opts); } - const adUnits = nonTsSlots.map((slot) => { + targetSlots.forEach(clearRefreshTargeting); + + const adUnits = targetSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); const zone = injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); @@ -638,8 +652,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { adUnits, bidsBackHandler: () => { pbjs.setTargetingForGPTAsync?.(); - // Refresh only the non-TS slots (pass explicit list so TS slots are not re-refreshed). - originalRefresh(nonTsSlots, opts); + originalRefresh(targetSlots, opts); }, timeout: timeoutMs, }); diff --git a/crates/js/lib/test/integrations/gpt/index.test.ts b/crates/js/lib/test/integrations/gpt/index.test.ts index 839b121d..08cedfc3 100644 --- a/crates/js/lib/test/integrations/gpt/index.test.ts +++ b/crates/js/lib/test/integrations/gpt/index.test.ts @@ -216,6 +216,103 @@ describe('GPT – installSlimPrebidLoader', () => { }); }); +describe('GPT – installTsAdInit', () => { + beforeEach(() => { + document.body.innerHTML = ''; + delete (window as any).tsjs; + delete (window as any).googletag; + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).tsjs; + delete (window as any).googletag; + }); + + it('clears stale TS-managed targeting before applying a new route to a reused GPT slot', async () => { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + const slotTargeting = new Map([ + ['hb_pb', ['1.20']], + ['hb_bidder', ['kargo']], + ['hb_adid', ['old-ad']], + ['hb_cache_host', ['cache.example.com']], + ['hb_cache_path', ['/cache']], + ['ts_initial', ['1']], + ['pos', ['old-pos']], + ]); + const gptSlot: any = { + getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), + getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), + setTargeting: vi.fn((key: string, value: string | string[]) => { + slotTargeting.set(key, Array.isArray(value) ? value : [value]); + return gptSlot; + }), + clearTargeting: vi.fn((key?: string) => { + if (key) { + slotTargeting.delete(key); + } else { + slotTargeting.clear(); + } + return gptSlot; + }), + }; + const pubads = { + getSlots: vi.fn(() => [gptSlot]), + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const cmd: Array<() => void> = []; + cmd.push = (...callbacks: Array<() => void>) => { + callbacks.forEach((callback) => callback()); + return cmd.length; + }; + + document.body.innerHTML = '
'; + (window as any).googletag = { + cmd, + pubads: () => pubads, + defineSlot: vi.fn(), + destroySlots: vi.fn(), + enableServices: vi.fn(), + }; + (window as any).tsjs = { + prevSlotTargetingKeys: { + 'div-ad-homepage-header': ['pos'], + }, + adSlots: [ + { + id: 'homepage_header_ad', + gam_unit_path: '/123/homepage', + div_id: 'div-ad-homepage-header', + formats: [[728, 90]], + targeting: { zone: 'homepage' }, + }, + ], + bids: {}, + }; + + installTsAdInit(); + (window as any).tsjs.adInit(); + + expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_bidder'); + expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_host'); + expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(gptSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(gptSlot.clearTargeting).toHaveBeenCalledWith('pos'); + expect(slotTargeting.get('hb_pb')).toBeUndefined(); + expect(slotTargeting.get('hb_bidder')).toBeUndefined(); + expect(slotTargeting.get('hb_adid')).toBeUndefined(); + expect(slotTargeting.get('hb_cache_host')).toBeUndefined(); + expect(slotTargeting.get('hb_cache_path')).toBeUndefined(); + expect(slotTargeting.get('pos')).toBeUndefined(); + expect(slotTargeting.get('zone')).toEqual(['homepage']); + expect(slotTargeting.get('ts_initial')).toEqual(['1']); + }); +}); + describe('GPT shim – runtime gating', () => { type GatedWindow = Window & { __tsjs_gpt_enabled?: boolean; diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index c79bfd08..18f8dd8c 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -769,6 +769,7 @@ describe('prebid/installPrebidNpm with server-injected config', () => { describe('prebid/installRefreshHandler', () => { beforeEach(() => { vi.clearAllMocks(); + mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; mockPbjs.adUnits = []; (window as any).tsjs = undefined; @@ -833,6 +834,85 @@ describe('prebid/installRefreshHandler', () => { }) ); }); + + it('auctions refreshed TS initial slots and clears stale TS targeting before refresh', () => { + const originalRefresh = vi.fn(); + const clearTargeting = vi.fn(); + const gptSlot = { + getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), + getTargeting: vi.fn((key: string) => { + if (key === 'ts_initial') return ['1']; + if (key === 'zone') return ['homepage']; + return []; + }), + getSizes: vi.fn(() => [ + { getWidth: () => 970, getHeight: () => 250 }, + { getWidth: () => 728, getHeight: () => 90 }, + ]), + clearTargeting, + }; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => [gptSlot]), + }; + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).tsjs = { + adSlots: [ + { + id: 'homepage_header_ad', + gam_unit_path: '/123/homepage', + div_id: 'div-ad-homepage-header', + formats: [ + [970, 250], + [728, 90], + ], + targeting: { zone: 'homepage' }, + }, + ], + }; + + installRefreshHandler(750); + pubads.refresh([gptSlot]); + + expect(mockRequestBids).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 750, + adUnits: [ + expect.objectContaining({ + code: 'div-ad-homepage-header', + mediaTypes: { + banner: { + name: 'homepage', + sizes: [ + [970, 250], + [728, 90], + ], + }, + }, + bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], + }), + ], + }) + ); + expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); + expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(originalRefresh).not.toHaveBeenCalled(); + + const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler; + bidsBackHandler(); + + expect(setTargetingForGPTAsync).toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); + }); }); describe('prebid/client-side bidders', () => { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ef526cca..1c4c7e02 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1034,10 +1034,7 @@ pub async fn handle_publisher_request( ); let consent_context = ec_context.consent().clone(); - let ec_id = ec_context - .ec_value() - .map(str::to_string) - .unwrap_or_default(); + let ec_id = ec_context.ec_value().filter(|_| ec_allowed); let cookie_jar = handle_request_cookies(&http_req)?; let geo = ec_context.geo_info().cloned(); @@ -1120,7 +1117,7 @@ pub async fn handle_publisher_request( }; let mut auction_request = build_auction_request( &slots_ctx, - &ec_id, + ec_id, &consent_context, &request_info, req.get_header_str("user-agent"), @@ -1129,7 +1126,11 @@ pub async fn handle_publisher_request( .as_ref() .and_then(|j| j.get(COOKIE_TS_EIDS)) .map(|c| c.value().to_owned()); - let client_eids = resolve_client_auction_eids(None, ts_eids_value.as_deref()); + let client_eids = if ec_id.is_some() { + resolve_client_auction_eids(None, ts_eids_value.as_deref()) + } else { + None + }; let kv_eids = resolve_auction_eids(kv, auction.registry, ec_context); let merged_eids = merge_auction_eids(client_eids, kv_eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); @@ -1316,7 +1317,7 @@ pub(crate) struct MatchedSlotsContext<'a> { /// Build an [`AuctionRequest`] from matched creative opportunity slots. pub(crate) fn build_auction_request( slots_ctx: &MatchedSlotsContext<'_>, - ec_id: &str, + ec_id: Option<&str>, consent_context: &crate::consent::ConsentContext, request_info: &crate::http_util::RequestInfo, user_agent: Option<&str>, @@ -1330,15 +1331,20 @@ pub(crate) fn build_auction_request( "{}://{}{}", request_info.scheme, request_info.host, slots_ctx.request_path ); + let ec_id = ec_id.filter(|id| !id.is_empty()); + let request_id = ec_id.map_or_else( + || format!("ts-req-{}", uuid::Uuid::new_v4().simple()), + |id| format!("ts-{id}"), + ); AuctionRequest { - id: format!("ts-{}", ec_id), + id: request_id, slots, publisher: PublisherInfo { domain: request_info.host.clone(), page_url: Some(page_url.clone()), }, user: UserInfo { - id: Some(ec_id.to_string()), + id: ec_id.map(str::to_string), consent: Some(consent_context.clone()), eids: None, }, @@ -1477,7 +1483,7 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map should be infallible"); let escaped = html_escape_for_script(&json); format!( - "", + "", escaped ) } @@ -1592,7 +1598,7 @@ pub async fn handle_page_bids( EcContext::read_from_request(settings, &req).change_context(TrustedServerError::Proxy { message: "page-bids: failed to read EC context".to_string(), })?; - let ec_id = ec_ctx.ec_value().map(str::to_string).unwrap_or_default(); + let ec_id = ec_ctx.ec_value().filter(|_| ec_ctx.ec_allowed()); let consent_context = ec_ctx.consent().clone(); let geo = ec_ctx.geo_info().cloned(); let cookie_jar = handle_request_cookies(&http_req)?; @@ -1631,7 +1637,7 @@ pub async fn handle_page_bids( }; let mut auction_request = build_auction_request( &slots_ctx, - &ec_id, + ec_id, &consent_context, &request_info, req.get_header_str("user-agent"), @@ -1640,7 +1646,11 @@ pub async fn handle_page_bids( .as_ref() .and_then(|j| j.get(COOKIE_TS_EIDS)) .map(|c| c.value().to_owned()); - let client_eids = resolve_client_auction_eids(None, ts_eids_value.as_deref()); + let client_eids = if ec_id.is_some() { + resolve_client_auction_eids(None, ts_eids_value.as_deref()) + } else { + None + }; let kv_eids = resolve_auction_eids(kv, registry, &ec_ctx); let merged_eids = merge_auction_eids(client_eids, kv_eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); @@ -2922,12 +2932,15 @@ mod tests { #[cfg(test)] mod creative_opportunities_tests { use super::super::{ - build_ad_slots_script, build_bid_map, build_bids_script, html_escape_for_script, + build_ad_slots_script, build_auction_request, build_bid_map, build_bids_script, + html_escape_for_script, MatchedSlotsContext, }; use crate::auction::types::{Bid, MediaType}; + use crate::consent::ConsentContext; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, }; + use crate::http_util::RequestInfo; use crate::price_bucket::PriceGranularity; use std::collections::HashMap; @@ -3350,6 +3363,88 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in bids script"); } + #[test] + fn bids_script_calls_ad_init_without_retry_timer() { + 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); + + assert!( + script.contains("window.tsjs.adInit"), + "should hand off bids to adInit" + ); + assert!( + !script.contains("setTimeout"), + "should not retry adInit on a timer" + ); + assert!( + !script.contains("prevGptSlots"), + "should not use TS-owned slots as adInit success signal" + ); + } + + #[test] + fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path: "/2024/01/my-article/", + }; + let request_info = RequestInfo { + host: "publisher.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + None, + &ConsentContext::default(), + &request_info, + Some("Mozilla/5.0"), + ); + + assert_eq!(request.user.id, None, "should not forward an EC user id"); + assert!( + request.id.starts_with("ts-req-"), + "should use a non-EC request id, got {}", + request.id + ); + } + + #[test] + fn auction_request_with_ec_id_sets_user_id_and_ec_request_id() { + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path: "/2024/01/my-article/", + }; + let request_info = RequestInfo { + host: "publisher.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + Some("ec-abc"), + &ConsentContext::default(), + &request_info, + Some("Mozilla/5.0"), + ); + + assert_eq!( + request.user.id.as_deref(), + Some("ec-abc"), + "should forward EC id when identity consent allows it" + ); + assert_eq!( + request.id, "ts-ec-abc", + "should preserve existing EC-derived request id when present" + ); + } + #[test] fn html_escape_encodes_special_chars() { assert_eq!( diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 3a55bc3d..d7595881 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -373,8 +373,8 @@ This is why mediation is important when using APS: without a mediator, APS bids ```toml [integrations.aps] enabled = true -pub_id = "5128" -endpoint = "https://aax.amazon-adsystem.com/e/dtb/bid" +pub_id = "example-publisher" +endpoint = "https://aps.example.com/e/dtb/bid" timeout_ms = 800 ``` @@ -593,8 +593,8 @@ debug = false [integrations.aps] enabled = true -pub_id = "5128" -endpoint = "https://aax.amazon-adsystem.com/e/dtb/bid" +pub_id = "example-publisher" +endpoint = "https://aps.example.com/e/dtb/bid" timeout_ms = 800 [integrations.adserver_mock] @@ -629,12 +629,12 @@ price_floor = 0.50 #### `[integrations.aps]` -| Field | Type | Default | Description | -| ------------ | ------ | ------------------------------------------- | --------------------------- | -| `enabled` | bool | `false` | Enable APS provider | -| `pub_id` | string | — | APS publisher ID (required) | -| `endpoint` | string | `https://aax.amazon-adsystem.com/e/dtb/bid` | APS TAM endpoint | -| `timeout_ms` | u32 | `800` | Request timeout | +| Field | Type | Default | Description | +| ------------ | ------ | ----------------------------------- | --------------------------- | +| `enabled` | bool | `false` | Enable APS provider | +| `pub_id` | string | — | APS publisher ID (required) | +| `endpoint` | string | `https://aps.example.com/e/dtb/bid` | APS TAM endpoint | +| `timeout_ms` | u32 | `800` | Request timeout | #### `[integrations.adserver_mock]` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a7d93d1a..aceec9fa 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -85,7 +85,7 @@ secret_store_id = "01GYYY" [integrations.prebid] enabled = true -server_url = "https://prebid-server.com/openrtb2/auction" +server_url = "https://prebid-server.example.com/openrtb2/auction" timeout_ms = 1200 bidders = ["kargo", "appnexus", "openx"] client_side_bidders = ["rubicon"] @@ -901,8 +901,8 @@ timeout_ms = 2000 [integrations.aps] enabled = true -pub_id = "5128" -endpoint = "https://aax.amazon-adsystem.com/e/dtb/bid" +pub_id = "example-publisher" +endpoint = "https://aps.example.com/e/dtb/bid" [integrations.prebid] enabled = true diff --git a/trusted-server.toml b/trusted-server.toml index efff7469..b8d3c50b 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -57,8 +57,8 @@ config_store_id = "" # set config/secret store ids for k secret_store_id = "" [integrations.prebid] -enabled = true -server_url = "http://68.183.113.79:8000" +enabled = false +server_url = "https://prebid-server.example.com/openrtb2/auction" timeout_ms = 1000 bidders = ["kargo", "appnexus", "openx"] debug = false @@ -215,8 +215,8 @@ rewrite_script = true # ] [auction] -enabled = true -providers = ["prebid", "aps"] +enabled = false +providers = ["prebid"] # mediator = "adserver_mock" timeout_ms = 2000 # override per-publisher via TRUSTED_SERVER__AUCTION__TIMEOUT_MS # Context keys the JS client is allowed to forward into auction requests. @@ -224,9 +224,9 @@ timeout_ms = 2000 # override per-publisher via TRUSTED_SERVER__AUCTION__TIMEOUT allowed_context_keys = ["permutive_segments"] [integrations.aps] -enabled = true -pub_id = "test-pub" -endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" +enabled = false +pub_id = "example-publisher" +endpoint = "https://aps.example.com/e/dtb/bid" timeout_ms = 1000 # override per-publisher via TRUSTED_SERVER__INTEGRATIONS__APS__TIMEOUT_MS [integrations.google_tag_manager] @@ -235,8 +235,8 @@ container_id = "GTM-XXXXXX" # upstream_url = "https://www.googletagmanager.com" [integrations.adserver_mock] -enabled = true -endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" +enabled = false +endpoint = "https://mediator.example.com/adserver/mediate" timeout_ms = 1000 # Debug configuration (all flags default to false — do not enable in production) @@ -271,7 +271,7 @@ timeout_ms = 1000 permutive_segments = "permutive" [creative_opportunities] -gam_network_id = "88059007" +gam_network_id = "123456789" # FCP is not affected by this value — body content above has already # streamed and painted before the hold begins. What this caps is the slip on # DOMContentLoaded and window.load. Worst case: a cache-hit page where origin @@ -281,50 +281,7 @@ gam_network_id = "88059007" auction_timeout_ms = 1500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS price_granularity = "dense" -# Slot templates — override entire array via: +# No slot templates are enabled in the checked-in default config. Add +# `[[creative_opportunities.slot]]` entries via private config or override the +# entire array via: # TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' - -[[creative_opportunities.slot]] -id = "atf_sidebar_ad" -gam_unit_path = "/a/b/news" -div_id = "div-ad-atf-sidebar" -page_patterns = ["/20**", "/news/**"] -formats = [{ width = 300, height = 250 }] -floor_price = 0.50 - -[creative_opportunities.slot.targeting] -pos = "atf" -zone = "atfSidebar" - -[creative_opportunities.slot.providers.aps] -slot_id = "aps-slot-atf-sidebar" - -[[creative_opportunities.slot]] -id = "homepage_header_ad" -gam_unit_path = "/a/b/homepage" -div_id = "div-ad-homepage-header" -page_patterns = ["/"] -formats = [{ width = 728, height = 90 }] -floor_price = 0.50 - -[creative_opportunities.slot.targeting] -pos = "atf" -zone = "header" - -[creative_opportunities.slot.providers.aps] -slot_id = "aps-slot-homepage-header" - -[[creative_opportunities.slot]] -id = "homepage_footer_ad" -gam_unit_path = "/a/b/homepage" -div_id = "div-ad-homepage-footer" -page_patterns = ["/"] -formats = [{ width = 728, height = 90 }, { width = 768, height = 66 }] -floor_price = 0.50 - -[creative_opportunities.slot.targeting] -pos = "btf" -zone = "fixedBottom" - -[creative_opportunities.slot.providers.aps] -slot_id = "aps-slot-homepage-footer"