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

vs client-side today: ~3,250ms ad-visible / FCP ~500ms+ +``` + +### 5.2 Cache-Hit Sequence (typical for content publisher pages) + +This is the common case for anonymous visitors on cacheable content pages. + ``` t=0ms GET ts.publisher.com/article arrives at Fastly edge t=1ms URL matched against creative-opportunities.toml Slots matched: [atf_sidebar_ad, below-content-ad, section_ad] Consent check: TCF consent present → auction proceeds + Request ID minted: 550e8400-e29b-41d4-a716-446655440000 -t=2ms AuctionOrchestrator.run_auction() called +t=2ms AuctionOrchestrator.run_auction() dispatched (parallel) PBS + APS dispatched in parallel via send_async() Edge→PBS RTT: ~20–30ms + Fastly cache lookup dispatched in parallel + __ts_ad_slots + __ts_request_id ".to_string() + r#""# + .to_string() ), - ad_bids_script: None, }; let mut processor = create_html_processor(config); let output = processor .process_chunk(b"T", true) .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); - assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots"); + assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots at head-open"); + assert!(html.contains("window.__ts_request_id"), "should inject request_id at head-open"); } #[test] - fn injects_bids_before_end_of_head() { - let bids_script = ""; + fn does_not_hold_end_of_head() { + // Verify: no bid data appears before — that hold was rejected by spec §4.3 let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, - ad_bids_script: Some(bids_script.to_string()), }; let mut processor = create_html_processor(config); let output = processor .process_chunk(b"T", true) .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); - assert!(html.contains("window.__ts_bids"), "should inject bids"); - let bids_pos = html.find("window.__ts_bids").expect("should find bids"); - let end_head_pos = html.find("").expect("should find "); - assert!(bids_pos < end_head_pos, "bids script should appear before "); + assert!(!html.contains("__ts_bids"), "must not inject bids into head"); } ``` Run: `cargo test -p trusted-server-core html_processor` - Expected: compile error (no `ad_slots_script`/`ad_bids_script` fields, no `empty_for_tests()`) + Expected: compile error (no `ad_slots_script` field, no `empty_for_tests()`) - [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`** @@ -888,7 +886,6 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in #[cfg(test)] impl IntegrationRegistry { pub fn empty_for_tests() -> Self { - // Minimal registry with no integrations for unit testing html_processor Self { inner: Arc::new(RegistryInner { proxies: Default::default(), @@ -905,7 +902,9 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in (Adjust field names to match the actual `RegistryInner` struct.) -- [ ] **Step 3: Add fields to `HtmlProcessorConfig`** +- [ ] **Step 3: Add single field to `HtmlProcessorConfig`** + + Replace any existing `ad_slots_script`/`ad_bids_script` fields with: ```rust pub struct HtmlProcessorConfig { @@ -913,56 +912,47 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, - /// Pre-computed `` for matched slots. - /// Injected at open, before integration head inserts. `None` when no slots matched. + /// Pre-computed ``. + /// Injected at `` open, before integration head inserts. `None` when no slots matched. pub ad_slots_script: Option, - /// Pre-computed `` for winning bids. - /// Injected immediately before via on_end_tag(). `None` when auction not run. - pub ad_bids_script: Option, } ``` - Update `from_settings` to initialize `ad_slots_script: None, ad_bids_script: None`. + Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_slots_script: None`. -- [ ] **Step 4: Inject `__ts_ad_slots` at head-open AND register `on_end_tag` for `__ts_bids`** +- [ ] **Step 4: Inject `ad_slots_script` at head-open** - In `create_html_processor`, within the EXISTING single `element!("head", ...)` handler, make two changes: - 1. Prepend the ad slots script BEFORE the existing integration inserts: + In `create_html_processor`, within the EXISTING `element!("head", ...)` handler, build the full snippet string with `ad_slots_script` first (so it appears first in output — lol_html `prepend` inserts before children, with **last-prepend-wins** ordering, so we call `prepend` exactly once with the full combined string): - ```rust - // NEW: inject __ts_ad_slots first - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); - } - // ... existing: for insert in integrations.head_inserts(&ctx) { ... } - ``` + ```rust + let ad_slots_script = config.ad_slots_script.clone(); + // ... existing captures ... - 2. After `el.prepend(...)`, register the end-tag handler for `__ts_bids`: - ```rust - // Register on_end_tag handler for __ts_bids injection before - if let Some(bids_script) = ad_bids_script.clone() { - el.on_end_tag(move |end_tag| { - end_tag.before(&bids_script, ContentType::Html); - Ok(()) - })?; - } - ``` + element!("head", |el| { + let mut snippet = String::new(); - Both changes live inside the same `element!("head", ...)` closure — no second handler needed. + // ad_slots_script first so __ts_ad_slots + __ts_request_id appear before + // integration inserts. DO NOT call prepend multiple times — lol_html stacks + // prepend calls in reverse order, so a single prepend with the full string + // guarantees correct ordering. + if let Some(ref slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } - Capture `ad_slots_script` and `ad_bids_script` into the closure the same way as `injected_tsjs`: + // ... existing: for insert in integrations.head_inserts(&ctx) { snippet.push_str(...) } - ```rust - let ad_slots_script = config.ad_slots_script.clone(); - let ad_bids_script = config.ad_bids_script.clone(); + if !snippet.is_empty() { + el.prepend(&snippet, ContentType::Html); + } + // DO NOT register on_end_tag — flushes immediately per spec §4.3 + Ok(()) + }) ``` - > **lol_html `on_end_tag` API note:** `Element::on_end_tag(handler)` is available in lol_html ≥2.0. The handler receives `&mut EndTag` and must return `Result<(), Box>`. Use `ContentType::Html` so the injected `", escaped) + let slots_json_str = serde_json::to_string(&slots_json) + .expect("should serialize ad slots"); + let escaped_slots = html_escape_for_script(&slots_json_str); + // request_id is a UUID (hex + hyphens only) — safe to embed without escaping. + format!( + r#""# + ) } - pub(crate) fn build_ad_bids_script( + /// Build the `BidMap` stored in `bid_cache` and returned by `/ts-bids`. + /// + /// Keyed by slot ID. Values contain `hb_pb`, `hb_bidder`, `hb_adid`, `burl`. + pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, price_granularity: crate::price_bucket::PriceGranularity, - ) -> String { - let bids_map: serde_json::Map = winning_bids + ) -> crate::bid_cache::BidMap { + winning_bids .iter() .filter_map(|(slot_id, bid)| { let cpm = bid.price?; - let entry = serde_json::json!({ - "hb_pb": price_bucket(cpm, price_granularity), - "hb_bidder": bid.bidder, - "hb_adid": bid.ad_id.as_deref().unwrap_or(""), - "burl": bid.burl, - }); - Some((slot_id.clone(), entry)) + let entry: std::collections::HashMap = [ + ("hb_pb".to_string(), serde_json::Value::String(price_bucket(cpm, price_granularity))), + ("hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone())), + ("hb_adid".to_string(), serde_json::Value::String( + bid.ad_id.as_deref().unwrap_or("").to_string() + )), + ("burl".to_string(), bid.burl.as_deref() + .map(serde_json::Value::from) + .unwrap_or(serde_json::Value::Null)), + ].into_iter().collect(); + Some((slot_id.clone(), entry.into_iter() + .map(|(k, v)| (k, v)) + .collect::>() + .into())) }) - .collect(); - let json = serde_json::to_string(&serde_json::Value::Object(bids_map)) - .expect("should serialize bids"); - let escaped = html_escape_for_script(&json); - format!("", escaped) + .collect() } /// HTML-escape a JSON string for safe inline `" .to_string(), - // __tsAdInit definition — reads window.__ts_ad_slots / __ts_bids at call time. + // __tsAdInit: fetches /ts-bids for bid targeting, then drives GPT. + // window.__ts_ad_slots and window.__ts_request_id are injected at head-open by TS. + // bidsPromise resolves concurrently with page rendering — never blocks FCP. concat!( "" @@ -1394,20 +1825,20 @@ The `HtmlProcessorConfig` fields now exist (Task 7). This task wires the auction ```bash git add crates/trusted-server-core/src/integrations/gpt.rs - git commit -m "Emit __tsAdInit function definition from GPT head injector" + git commit -m "Emit __tsAdInit with /ts-bids fetch pattern from GPT head injector" ``` --- -## Task 10: `gpt/index.ts` — TypeScript `__tsAdInit` +## Task 12: `gpt/index.ts` — TypeScript `__tsAdInit` with `/ts-bids` fetch **Files:** - Modify: `crates/js/lib/src/integrations/gpt/index.ts` -The TypeScript version is the authoritative implementation; it must mirror the Rust inline string from Task 9 exactly. +The TypeScript version mirrors the Rust inline string from Task 11. It uses the `bidsPromise` pattern — fetching `/ts-bids` concurrently with GPT slot definition. -- [ ] **Step 1: Write a failing test** +- [ ] **Step 1: Write failing tests** In `crates/js/lib/src/integrations/gpt/index.test.ts`: @@ -1417,20 +1848,21 @@ The TypeScript version is the authoritative implementation; it must mirror the R describe('installTsAdInit', () => { beforeEach(() => { delete (window as any).__ts_ad_slots - delete (window as any).__ts_bids + delete (window as any).__ts_request_id delete (window as any).__tsAdInit }) - it('defines googletag slots from __ts_ad_slots and calls refresh', () => { + it('fetches /ts-bids with request_id and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue([]), } const mockPubads = { enableSingleRequest: vi.fn(), addEventListener: vi.fn(), refresh: vi.fn(), - getTargeting: vi.fn().mockReturnValue([]), } ;(window as any).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, @@ -1447,45 +1879,131 @@ The TypeScript version is the authoritative implementation; it must mirror the R targeting: { pos: 'atf' }, }, ] - ;(window as any).__ts_bids = { - atf: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - burl: 'https://ssp/bill', - }, - } + ;(window as any).__ts_request_id = 'test-rid-123' + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + burl: 'https://ssp/bill', + }, + }), + } as Response) - // Must import installTsAdInit from the module - const { installTsAdInit } = require('./index') + const { installTsAdInit } = await import('./index') installTsAdInit() - ;(window as any).__tsAdInit() + await (window as any).__tsAdInit() - expect((window as any).googletag.defineSlot).toHaveBeenCalledWith( - '/123/atf', - [[300, 250]], - 'atf' + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/ts-bids?rid=test-rid-123'), + expect.objectContaining({ credentials: 'omit' }) ) expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00') expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo') expect(mockPubads.refresh).toHaveBeenCalled() + + fetchSpy.mockRestore() + }) + + it('calls refresh with empty bids when fetch fails', async () => { + const mockPubads = { + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + } + ;(window as any).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + }), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + } + ;(window as any).__ts_ad_slots = [] + ;(window as any).__ts_request_id = 'rid-fail' + + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')) + + const { installTsAdInit } = await import('./index') + installTsAdInit() + await (window as any).__tsAdInit() + + expect(mockPubads.refresh).toHaveBeenCalled() }) - it('fires burl via sendBeacon on slotRenderEnded when our bid won', () => { + it('fires burl via sendBeacon on slotRenderEnded when our bid won', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) - // ... setup and trigger slotRenderEnded event - // Verify: navigator.sendBeacon called with burl + let capturedListener: ((e: any) => void) | undefined + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue(['abc']), + } + const mockPubads = { + enableSingleRequest: vi.fn(), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: any) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn + }), + } + ;(window as any).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + } + ;(window as any).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: {}, + }, + ] + ;(window as any).__ts_request_id = 'rid-burl-test' + + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + burl: 'https://ssp/bill', + }, + }), + } as Response) + + const { installTsAdInit } = await import('./index') + installTsAdInit() + await (window as any).__tsAdInit() + + // Trigger slotRenderEnded — slot has our winning hb_adid + expect(capturedListener).toBeDefined() + capturedListener!({ + isEmpty: false, + slot: mockSlot, + }) + + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill') beaconSpy.mockRestore() }) }) ``` Run: `cd crates/js/lib && npx vitest run` - Expected: FAIL — `installTsAdInit` not exported + Expected: FAIL — `installTsAdInit` not exported or fetches wrong endpoint - [ ] **Step 2: Add `installTsAdInit` to `index.ts`** - Add to `crates/js/lib/src/integrations/gpt/index.ts` (bottom of file): + Add to `crates/js/lib/src/integrations/gpt/index.ts`: ```typescript interface TsAdSlot { @@ -1505,60 +2023,87 @@ The TypeScript version is the authoritative implementation; it must mirror the R type TsWindow = Window & { __ts_ad_slots?: TsAdSlot[] - __ts_bids?: Record + __ts_request_id?: string __tsAdInit?: () => void } /** - * Install `window.__tsAdInit` — reads `window.__ts_ad_slots` and `window.__ts_bids` - * (injected by the edge into ), defines GPT slots, applies pre-won bid targeting, - * registers a `slotRenderEnded` listener to fire `burl` via `sendBeacon`, then calls - * `refresh()`. + * Install `window.__tsAdInit`. + * + * Reads `window.__ts_ad_slots` and `window.__ts_request_id` (both injected by + * the edge at `` open). Fetches bid results from `/ts-bids?rid=` + * concurrently with GPT slot definition. Applies targeting and calls `refresh()` + * after the fetch resolves. Registers `slotRenderEnded` to fire `burl` via + * `sendBeacon` when our specific Prebid bid wins the GAM line item match. */ export function installTsAdInit(): void { const w = window as TsWindow w.__tsAdInit = function () { const slots = w.__ts_ad_slots ?? [] - const bids = w.__ts_bids ?? {} + const rid = w.__ts_request_id + + const bidsPromise: Promise> = rid + ? fetch(`/ts-bids?rid=${encodeURIComponent(rid)}`, { + credentials: 'omit', + }) + .then((r) => (r.ok ? r.json() : {})) + .catch(() => ({})) + : Promise.resolve({}) + const g = (window as GptWindow).googletag if (!g) return + g.cmd.push(() => { - slots.forEach((slot) => { - const gptSlot = g.defineSlot?.( - slot.gam_unit_path, - slot.formats, - slot.div_id - ) - if (!gptSlot) return - gptSlot.addService(g.pubads()) - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => - gptSlot.setTargeting(k, v) - ) - const bid = bids[slot.id] ?? {} - ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + const gptSlots = slots + .map((slot) => { + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats, + slot.div_id + ) + if (!gptSlot) return null + gptSlot.addService(g.pubads()) + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => + gptSlot.setTargeting(k, v) + ) + return { id: slot.id, gptSlot } }) - }) + .filter(Boolean) as Array<{ + id: string + gptSlot: NonNullable> + }> + g.pubads().enableSingleRequest() g.enableServices() - g.pubads().addEventListener?.('slotRenderEnded', (event: any) => { - const slotId: string = event.slot?.getSlotElementId?.() ?? '' - const bid = bids[slotId] ?? {} - if ( - !event.isEmpty && - bid.burl && - event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid - ) { - navigator.sendBeacon(bid.burl) - } + + bidsPromise.then((bids) => { + gptSlots.forEach(({ id, gptSlot }) => { + const bid = bids[id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + }) + + g.pubads().addEventListener?.('slotRenderEnded', (event: any) => { + const slotId: string = event.slot?.getSlotElementId?.() ?? '' + const bid = bids[slotId] ?? {} + if ( + !event.isEmpty && + bid.burl && + event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + ) { + navigator.sendBeacon(bid.burl) + } + }) + + g.pubads().refresh() }) - g.pubads().refresh() }) } } ``` - Call `installTsAdInit()` from the integration's initialization path so it's set up when the bundle loads. + Call `installTsAdInit()` from the integration's initialization path. - [ ] **Step 3: Run JS tests** @@ -1574,12 +2119,12 @@ The TypeScript version is the authoritative implementation; it must mirror the R ```bash git add crates/js/lib/src/integrations/gpt/ - git commit -m "Add __tsAdInit and slotRenderEnded burl firing to GPT integration" + git commit -m "Add installTsAdInit with /ts-bids fetch pattern and slotRenderEnded burl firing" ``` --- -## Task 11: `nurl` fire-and-forget +## Task 13: `nurl` fire-and-forget **Files:** @@ -1610,9 +2155,9 @@ The TypeScript version is the authoritative implementation; it must mirror the R fn default_fire_nurl_at_edge() -> bool { true } ``` -- [ ] **Step 3: Fire nurls in publisher.rs after auction** +- [ ] **Step 3: Fire nurls in publisher.rs after bid_cache.put()** - After `auction_result` is obtained, add: + After the `bid_cache.put(...)` call (Task 9 Step 3), add: ```rust if let Some(ref result) = auction_result { @@ -1620,7 +2165,7 @@ The TypeScript version is the authoritative implementation; it must mirror the R } ``` - Add helper (no `.await` — fire-and-forget): + Add helper: ```rust fn fire_winning_nurls( @@ -1671,13 +2216,13 @@ The TypeScript version is the authoritative implementation; it must mirror the R --- -## Task 12: End-to-end integration tests +## Task 14: End-to-end integration tests **Files:** - Modify: `crates/trusted-server-core/src/publisher.rs` (test module) -Tests use `pub(crate)` helpers from Task 8 directly. +Tests use `pub(crate)` helpers from Task 9 directly. - [ ] **Step 1: Write tests** @@ -1686,7 +2231,7 @@ Tests use `pub(crate)` helpers from Task 8 directly. ```rust #[cfg(test)] mod creative_opportunities_tests { - use super::{build_ad_slots_script, build_ad_bids_script, html_escape_for_script}; + use super::{build_head_globals_script, build_bid_map, html_escape_for_script}; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunitySlot, CreativeOpportunityFormat, CreativeOpportunitiesFile, match_slots, @@ -1719,20 +2264,32 @@ Tests use `pub(crate)` helpers from Task 8 directly. } #[test] - fn ad_slots_script_is_safe_and_parseable() { + fn head_globals_script_contains_ad_slots_and_request_id() { let slots = vec![make_slot()]; let config = make_config(); - let script = build_ad_slots_script(&slots, &config); - assert!(script.contains("window.__ts_ad_slots=JSON.parse"), "should use JSON.parse"); + let rid = "550e8400-e29b-41d4-a716-446655440000"; + let script = build_head_globals_script(&slots, rid, &config); + assert!(script.contains("window.__ts_ad_slots=JSON.parse"), "should use JSON.parse for slots"); assert!(script.contains("atf_sidebar_ad"), "should include slot id"); - // Verify no raw < or > that could break HTML parser - let inner = script.trim_start_matches(""); + assert!(script.contains(&format!("window.__ts_request_id=\"{rid}\"")), "should include request_id"); + assert!(!script.contains("__ts_bids"), "must NOT contain bids — bids come from /ts-bids"); + } + + #[test] + fn head_globals_script_is_xss_safe() { + let slots = vec![make_slot()]; + let config = make_config(); + let script = build_head_globals_script(&slots, "safe-rid", &config); + // Strip outer "); assert!(!inner.contains('<'), "no unescaped < in script content"); assert!(!inner.contains('>'), "no unescaped > in script content"); } #[test] - fn ad_bids_script_uses_price_bucket_and_ad_id() { + fn bid_map_uses_price_bucket_and_ad_id() { let mut winning_bids = HashMap::new(); winning_bids.insert("atf_sidebar_ad".to_string(), Bid { slot_id: "atf_sidebar_ad".to_string(), @@ -1747,11 +2304,23 @@ Tests use `pub(crate)` helpers from Task 8 directly. ad_id: Some("prebid-uuid-abc123".to_string()), metadata: HashMap::new(), }); - let script = build_ad_bids_script(&winning_bids, PriceGranularity::Dense); - assert!(script.contains("\"hb_pb\":\"2.53\""), "should bucket 2.53 as 2.53 (dense)"); - assert!(script.contains("\"hb_bidder\":\"kargo\""), "should include bidder"); - assert!(script.contains("\"hb_adid\":\"prebid-uuid-abc123\""), "should use ad_id not creative markup"); - assert!(script.contains("burl"), "should include burl for billing"); + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let slot_bids = bid_map.get("atf_sidebar_ad").expect("should have slot bids"); + assert_eq!( + slot_bids.get("hb_pb").and_then(|v| v.as_str()), + Some("2.53"), + "should bucket 2.53 as 2.53 (dense)" + ); + assert_eq!( + slot_bids.get("hb_bidder").and_then(|v| v.as_str()), + Some("kargo"), + "should include bidder" + ); + assert_eq!( + slot_bids.get("hb_adid").and_then(|v| v.as_str()), + Some("prebid-uuid-abc123"), + "should use ad_id not creative markup" + ); } #[test] @@ -1795,7 +2364,7 @@ Tests use `pub(crate)` helpers from Task 8 directly. ```bash git add crates/trusted-server-core/src/publisher.rs - git commit -m "Add integration tests for creative opportunities pipeline (slots, bids, XSS)" + git commit -m "Add integration tests for creative opportunities pipeline (head globals, bid map, XSS)" ``` --- @@ -1804,19 +2373,27 @@ Tests use `pub(crate)` helpers from Task 8 directly. Run `fastly compute serve` and verify: -- [ ] **No match:** Request `/about` — no `__ts_ad_slots` or `__ts_bids` in response HTML, no `Cache-Control: private, no-store` -- [ ] **Match:** Request `/2024/01/article` — both globals present in ``, `Cache-Control: private, no-store` set -- [ ] **Empty file kill-switch:** Empty `creative-opportunities.toml` → no globals injected on any URL -- [ ] **Auction timeout:** Set `auction_timeout_ms = 1` → `__ts_bids` injects as `{}`, no slot entries -- [ ] **XSS check:** Add `targeting = { zone = ""# - .to_string() - ), - }; - let mut processor = create_html_processor(config); - let output = processor - .process_chunk(b"T", true) - .expect("should process"); - let html = std::str::from_utf8(&output).expect("should be utf8"); - assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots at head-open"); - assert!(html.contains("window.__ts_request_id"), "should inject request_id at head-open"); - } + Run: - #[test] - fn does_not_hold_end_of_head() { - // Verify: no bid data appears before — that hold was rejected by spec §4.3 - let config = HtmlProcessorConfig { - origin_host: "origin.example.com".to_string(), - request_host: "example.com".to_string(), - request_scheme: "https".to_string(), - integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: None, - }; - let mut processor = create_html_processor(config); - let output = processor - .process_chunk(b"T", true) - .expect("should process"); - let html = std::str::from_utf8(&output).expect("should be utf8"); - assert!(!html.contains("__ts_bids"), "must not inject bids into head"); - } + ```bash + cargo test -p trusted-server-core html_processor ``` - Run: `cargo test -p trusted-server-core html_processor` - Expected: compile error (no `ad_slots_script` field, no `empty_for_tests()`) + Expected: compile failure for missing `HtmlProcessorConfig::ad_slots_script`. -- [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`** +- [ ] **Step 2: Add config field** - In `registry.rs`, add: + Add to `HtmlProcessorConfig`: ```rust - #[cfg(test)] - impl IntegrationRegistry { - pub fn empty_for_tests() -> Self { - Self { - inner: Arc::new(RegistryInner { - proxies: Default::default(), - attribute_rewriters: Default::default(), - script_rewriters: Vec::new(), - html_post_processors: Vec::new(), - head_injectors: Vec::new(), - metadata: Default::default(), - }) - } - } - } + /// Precomputed head globals script. Contains ad slots and request ID only. + pub ad_slots_script: Option, ``` - (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: + Initialize it to `None` in `HtmlProcessorConfig::from_settings`. - ```rust - pub struct HtmlProcessorConfig { - pub origin_host: String, - pub request_host: String, - pub request_scheme: String, - pub integrations: IntegrationRegistry, - /// Pre-computed ``. - /// Injected at `` open, before integration head inserts. `None` when no slots matched. - pub ad_slots_script: Option, - } - ``` - - Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_slots_script: None`. +- [ ] **Step 3: Inject once at `` open** -- [ ] **Step 4: Inject `ad_slots_script` at head-open** + In the existing `element!("head", ...)` handler: + - Build one `snippet` string. + - Push `ad_slots_script` first when present. + - Then push integration head inserts. + - Then push tsjs script tags. + - Call `el.prepend(&snippet, ContentType::Html)` once. - 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): + Do not add `on_end_tag`. - ```rust - let ad_slots_script = config.ad_slots_script.clone(); - // ... existing captures ... - - element!("head", |el| { - let mut snippet = String::new(); - - // ad_slots_script first so __ts_ad_slots + __ts_request_id appear before - // integration inserts. DO NOT call prepend multiple times — lol_html stacks - // prepend calls in reverse order, so a single prepend with the full string - // guarantees correct ordering. - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); - } +- [ ] **Step 4: Add publisher script helpers** - // ... existing: for insert in integrations.head_inserts(&ctx) { snippet.push_str(...) } + In `publisher.rs`, add `pub(crate)` helpers: + - `build_head_globals_script(matched_slots, request_id, co_config) -> String` + - `html_escape_for_script(json: &str) -> String` - if !snippet.is_empty() { - el.prepend(&snippet, ContentType::Html); - } - // DO NOT register on_end_tag — flushes immediately per spec §4.3 - Ok(()) - }) - ``` + The script must use `JSON.parse("...escaped JSON...")` and must not interpolate raw JSON into executable JavaScript. - [ ] **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** + ```bash + cargo test -p trusted-server-core html_processor + ``` - Run: `cargo test --workspace` - Expected: clean + Expected: tests pass. -- [ ] **Step 7: Commit** +- [ ] **Step 6: 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" + git add crates/trusted-server-core/src/html_processor.rs crates/trusted-server-core/src/publisher.rs + git commit -m "Inject server-side ad globals at head open" ``` --- -## Task 8: `bid_cache.rs` — In-process auction result cache +## Task 9: Publisher Helpers for Bids, Consent, and Cache Headers **Files:** -- Create: `crates/trusted-server-core/src/bid_cache.rs` -- Modify: `crates/trusted-server-core/src/lib.rs` +- Modify: `crates/trusted-server-core/src/publisher.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. +- [ ] **Step 1: Write failing helper tests** -> **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. + Add tests covering: + - `build_head_globals_script` includes slots and request ID. + - script escaping neutralizes ``, `<`, `>`, `&`, U+2028, and U+2029. + - `build_bid_map` emits `hb_pb`, `hb_bidder`, `hb_adid`, and `burl`. + - bids with `price: None` are omitted. + - `server_side_auction_allowed` returns true only when TCF exists and purpose 1 is consented. + - response cache policy sets `Cache-Control: private, no-store` when slots matched and consent is denied. + - response cache policy sets `Cache-Control: private, no-store` when globals are injected. + - response cache policy does not remove `Surrogate-Control` or `Fastly-Surrogate-Control`. + - no-match responses preserve origin cache headers. -- [ ] **Step 1: Write failing tests** - - Create `crates/trusted-server-core/src/bid_cache.rs` with only the tests: - - ```rust - #[cfg(test)] - mod tests { - use super::*; - use std::time::{Duration, Instant}; + Run: - 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"); - } + ```bash + cargo test -p trusted-server-core publisher::creative_opportunities_tests + ``` - #[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"); - } + Expected: failure because helpers are missing. - #[test] - fn returns_bids_after_put() { - let cache = BidCache::new(Duration::from_secs(30), 100); - let deadline = Instant::now() + Duration::from_secs(5); - cache.mark_pending("rid-2", deadline); - cache.put("rid-2", make_bids()); - match cache.try_get("rid-2") { - CacheResult::Complete(bids) => { - assert!(bids.contains_key("atf"), "should contain atf bid"); - } - other => panic!("expected Complete, got {:?}", other), - } - } +- [ ] **Step 2: Implement bid map helper** - #[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"); - } + Implement `build_bid_map`: - #[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"); - } + ```rust + 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?; + Some(( + slot_id.clone(), + serde_json::json!({ + "hb_pb": crate::price_bucket::price_bucket(cpm, price_granularity), + "hb_bidder": bid.bidder, + "hb_adid": bid.ad_id.as_deref().unwrap_or(""), + "burl": bid.burl, + }), + )) + }) + .collect() } ``` - Run: `cargo test -p trusted-server-core bid_cache` - Expected: compile error (module not exported yet) +- [ ] **Step 3: Implement consent helper** -- [ ] **Step 2: Implement bid_cache.rs** + Implement a small helper used only by the server-side ad-template path: ```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, + fn server_side_auction_allowed(consent_context: &crate::consent::ConsentContext) -> bool { + consent_context + .tcf + .as_ref() + .is_some_and(|tcf| tcf.has_purpose_consent(1)) } + ``` - 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, - }), - } - } + This intentionally follows the April 15 design: absent TCF means no server-side auction and no ad globals for this Phase 1 path. - /// 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 }; - } - } +- [ ] **Step 4: Implement browser cache policy helper** - /// 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, - } - }) - } + Implement a helper that receives `slots_matched: bool` and `globals_injected: bool`. - /// 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)); - } - } - } - } - } - ``` + Required behavior: + - If no slots matched, do nothing. + - If slots matched and consent denied, set `Cache-Control: private, no-store`. + - If globals are injected, set `Cache-Control: private, no-store`. + - Preserve `Surrogate-Control` and `Fastly-Surrogate-Control`. -- [ ] **Step 3: Export from lib.rs** +- [ ] **Step 5: Run tests** - ```rust - pub mod bid_cache; + ```bash + cargo test -p trusted-server-core publisher::creative_opportunities_tests ``` -- [ ] **Step 4: Run tests** + Expected: tests pass. - Run: `cargo test -p trusted-server-core bid_cache` - Expected: all tests pass - -- [ ] **Step 5: Commit** +- [ ] **Step 6: 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/publisher.rs + git commit -m "Add publisher helpers for ad template responses" ``` --- -## Task 9: `handle_publisher_request` async restructuring +## Task 10: GPT `__tsAdInit` Bootstrap **Files:** -- Modify: `crates/trusted-server-core/src/publisher.rs` -- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` - -> **Key constraint from spec §4.3:** Page rendering is never held for the auction. The auction and origin fetch run concurrently via Fastly's `send_async()` model — origin is dispatched first (non-blocking), then the auction runs its own `send_async` calls, so both overlap on the network. Bid results go to `bid_cache` only — they are NOT injected into the HTML. `Cache-Control: private, no-store` is set whenever slots matched (not just when bids arrived). - -- [ ] **Step 1: Update function signature** - - Change `handle_publisher_request` in `publisher.rs`: - - ```rust - pub async fn handle_publisher_request( - settings: &Settings, - integration_registry: &IntegrationRegistry, - services: &RuntimeServices, - orchestrator: &crate::auction::orchestrator::AuctionOrchestrator, - slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, - bid_cache: &crate::bid_cache::BidCache, - mut req: Request, - ) -> Result> - ``` - - Add imports: - - ```rust - use crate::auction::orchestrator::AuctionOrchestrator; - use crate::auction::types::{AuctionContext, AuctionRequest, PublisherInfo, UserInfo, SiteInfo}; - use crate::bid_cache::{BidCache, BidMap}; - use crate::creative_opportunities::{CreativeOpportunitiesFile, match_slots}; - use crate::price_bucket::price_bucket; - ``` - -- [ ] **Step 2: Mint `request_id`, match URL, check consent** - - At the top of the function body, before the origin fetch: - - ```rust - // Mint per-request UUID — included in head injection and /ts-bids lookup key. - let request_id = uuid::Uuid::new_v4().to_string(); - - let request_path = req.get_path().to_string(); - let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() { - match_slots(&slots_file.slots, &request_path) - .into_iter() - .cloned() - .collect() - } else { - Vec::new() - }; - - let consent_allows_auction = consent_context - .tcf - .as_ref() - .map_or(false, |tcf| tcf.has_purpose_consent(1)); - let should_run_auction = !matched_slots.is_empty() && consent_allows_auction; - - let auction_timeout_ms = settings - .creative_opportunities - .as_ref() - .and_then(|co| co.auction_timeout_ms) - .unwrap_or(settings.auction.timeout_ms); - ``` - -- [ ] **Step 3: Register pending in bid_cache, fire origin + auction concurrently** - - ```rust - // Mint T₀ auction deadline. Stored in bid_cache so /ts-bids uses the same deadline, - // not a freshly-minted one when the browser's fetch arrives. - let auction_deadline = std::time::Instant::now() - + std::time::Duration::from_millis(u64::from(auction_timeout_ms)); - - // Register request as in-flight so /ts-bids can long-poll for it. - if should_run_auction { - bid_cache.mark_pending(&request_id, auction_deadline); - } - - restrict_accept_encoding(&mut req); - req.set_header("host", &origin_host); - - // Fire origin request immediately — Fastly's send_async dispatches the HTTP request - // to the network without blocking. The origin fetch is in-flight from this point. - // The auction below also uses send_async internally, so both origin SSP requests - // overlap on the network. This is Fastly's concurrency model — no join! needed. - let pending_origin = req - .send_async(&backend_name) - .change_context(TrustedServerError::Proxy { - message: "Failed to dispatch async origin request".to_string(), - })?; - - // Run auction (internal send_async calls overlap with origin fetch on the network). - let auction_result = if should_run_auction { - let co_config = settings.creative_opportunities.as_ref() - .expect("should be present when should_run_auction is true"); - let auction_request = build_auction_request( - &matched_slots, - &ec_id, - &consent_context, - &request_info, - co_config, - ); - let placeholder_req = fastly::Request::new(); - let auction_context = AuctionContext { - settings, - request: &placeholder_req, - client_info: &services.client_info, - timeout_ms: auction_timeout_ms, - provider_responses: None, - }; - match orchestrator.run_auction(&auction_request, &auction_context, services).await { - Ok(result) => Some(result), - Err(e) => { - log::warn!("server-side auction failed, proceeding without bids: {e:?}"); - None - } - } - } else { - None - }; - - // Write auction results to bid_cache — /ts-bids will serve them. - if should_run_auction { - let co_config = settings.creative_opportunities.as_ref() - .expect("should be present"); - // Bind empty map to a local to avoid &Default::default() referencing a temporary. - let empty_bids = std::collections::HashMap::new(); - let winning_bids = auction_result.as_ref() - .map(|r| &r.winning_bids) - .unwrap_or(&empty_bids); - let bid_map = build_bid_map(winning_bids, co_config.price_granularity); - bid_cache.put(&request_id, bid_map); - } - - // Await origin response (may already be buffered since we started it before the auction). - let mut response = pending_origin - .wait() - .change_context(TrustedServerError::Proxy { - message: "Failed to await origin response".to_string(), - })?; - ``` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/js/lib/src/integrations/gpt/index.test.ts` -- [ ] **Step 4: Build head injection script, set cache headers, force chunked encoding** +- [ ] **Step 1: Write failing Rust test** - After acquiring `response`: + In `gpt.rs`, add a test that combines `head_inserts()` output and asserts: + - contains `__tsAdInit` + - contains `/ts-bids` + - contains `__ts_request_id` + - contains `bidsPromise` + - contains `slotRenderEnded` + - contains `sendBeacon` + - does not contain `__ts_bids` - ```rust - // Build head injection script: __ts_ad_slots + __ts_request_id (never bids). - let ad_slots_script = if let Some(co_config) = &settings.creative_opportunities { - if !matched_slots.is_empty() { - Some(build_head_globals_script(&matched_slots, &request_id, co_config)) - } else { - None - } - } else { - None - }; - - // When slots matched: prevent browser/CDN caching of the per-user assembled HTML. - // Spec §4.4: set regardless of whether bids arrived — the request_id is now in the page. - if ad_slots_script.is_some() { - response.set_header(header::CACHE_CONTROL, "private, no-store"); - response.remove_header("surrogate-control"); - response.remove_header("fastly-surrogate-control"); - } + Run: - // Spec §4.3/§4.7: Force chunked encoding on every origin response so that - // reaches the browser immediately as chunks arrive — regardless of whether origin - // sent a buffered response (WordPress, Drupal) or a streaming one (NextJS 16). - // Removing Content-Length is required; sending both headers is invalid HTTP/1.1. - response.remove_header(header::CONTENT_LENGTH); - response.set_header("transfer-encoding", "chunked"); + ```bash + cargo test -p trusted-server-core integrations::gpt ``` -- [ ] **Step 5: Add `pub(crate)` helper functions** + Expected: failure because `__tsAdInit` is missing. - ```rust - /// Build the `"# - ) - } - - /// Build the `BidMap` stored in `bid_cache` and returned by `/ts-bids`. - /// - /// Keyed by slot ID. Values contain `hb_pb`, `hb_bidder`, `hb_adid`, `burl`. - pub(crate) fn build_bid_map( - winning_bids: &std::collections::HashMap, - price_granularity: crate::price_bucket::PriceGranularity, - ) -> crate::bid_cache::BidMap { - winning_bids - .iter() - .filter_map(|(slot_id, bid)| { - let cpm = bid.price?; - let entry: std::collections::HashMap = [ - ("hb_pb".to_string(), serde_json::Value::String(price_bucket(cpm, price_granularity))), - ("hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone())), - ("hb_adid".to_string(), serde_json::Value::String( - bid.ad_id.as_deref().unwrap_or("").to_string() - )), - ("burl".to_string(), bid.burl.as_deref() - .map(serde_json::Value::from) - .unwrap_or(serde_json::Value::Null)), - ].into_iter().collect(); - Some((slot_id.clone(), entry.into_iter() - .map(|(k, v)| (k, v)) - .collect::>() - .into())) - }) - .collect() - } +- [ ] **Step 2: Extend GPT Rust head injector** - /// HTML-escape a JSON string for safe inline `" - .to_string(), - // __tsAdInit: fetches /ts-bids for bid targeting, then drives GPT. - // window.__ts_ad_slots and window.__ts_request_id are injected at head-open by TS. - // bidsPromise resolves concurrently with page rendering — never blocks FCP. - concat!( - "" - ).to_string(), - ] - } - } + ```bash + cargo test -p trusted-server-core publisher ``` -- [ ] **Step 3: Run tests** + Expected: failures for missing integration. - Run: `cargo test -p trusted-server-core integrations::gpt` - Expected: all pass including new test +- [ ] **Step 2: Add platform pending-request polling** -- [ ] **Step 4: Commit** + Extend `PlatformHttpClient` with a non-blocking poll method: - ```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" + ```rust + async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result>; ``` ---- + `PlatformPollResult` must include: + - `Pending(PlatformPendingRequest)` + - `Ready(Result>)` -## Task 12: `gpt/index.ts` — TypeScript `__tsAdInit` with `/ts-bids` fetch + In the Fastly adapter, implement it with `fastly::http::request::PollResult` from `PendingRequest::poll()`. Non-Fastly/test implementations may return `PlatformError::Unsupported` until EdgeZero migration adds equivalent primitives. -**Files:** +- [ ] **Step 3: Add pollable auction progression** -- Modify: `crates/js/lib/src/integrations/gpt/index.ts` + Refactor auction orchestration without changing existing `/auction` behavior: + - Keep `AuctionOrchestrator::run_auction` for existing endpoints. + - Add a server-side-template path that can `start` provider requests and return a `PendingAuction`. + - `PendingAuction::poll_once()` must call platform `poll` and return immediately. + - `PendingAuction::finish_due_to_deadline()` must drop remaining pending requests and compute winners from responses collected so far. + - Parsing and winning-bid selection must reuse existing provider/orchestrator logic. -The TypeScript version mirrors the Rust inline string from Task 11. It uses the `bidsPromise` pattern — fetching `/ts-bids` concurrently with GPT slot definition. + This is the mechanism that lets publisher streaming continue while auction work advances opportunistically between streaming chunks. -- [ ] **Step 1: Write failing tests** +- [ ] **Step 4: Load creative opportunities and bid cache in adapter** - In `crates/js/lib/src/integrations/gpt/index.test.ts`: + In `main.rs`, add: - ```typescript - import { describe, it, expect, vi, beforeEach } from 'vitest' - - describe('installTsAdInit', () => { - beforeEach(() => { - delete (window as any).__ts_ad_slots - delete (window as any).__ts_request_id - delete (window as any).__tsAdInit - }) + ```rust + const CREATIVE_OPPORTUNITIES_TOML: &str = + include_str!("../../../creative-opportunities.toml"); + ``` - it('fetches /ts-bids with request_id and applies bid targeting before refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('atf'), - getTargeting: vi.fn().mockReturnValue([]), - } - const mockPubads = { - enableSingleRequest: vi.fn(), - addEventListener: vi.fn(), - refresh: vi.fn(), - } - ;(window as any).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - } - ;(window as any).__ts_ad_slots = [ - { - id: 'atf', - gam_unit_path: '/123/atf', - div_id: 'atf', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ] - ;(window as any).__ts_request_id = 'test-rid-123' - - const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - json: async () => ({ - atf: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - burl: 'https://ssp/bill', - }, - }), - } as Response) - - const { installTsAdInit } = await import('./index') - installTsAdInit() - await (window as any).__tsAdInit() - - expect(fetchSpy).toHaveBeenCalledWith( - expect.stringContaining('/ts-bids?rid=test-rid-123'), - expect.objectContaining({ credentials: 'omit' }) - ) - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00') - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo') - expect(mockPubads.refresh).toHaveBeenCalled() - - fetchSpy.mockRestore() - }) - - it('calls refresh with empty bids when fetch fails', async () => { - const mockPubads = { - enableSingleRequest: vi.fn(), - addEventListener: vi.fn(), - refresh: vi.fn(), - } - ;(window as any).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - }), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - } - ;(window as any).__ts_ad_slots = [] - ;(window as any).__ts_request_id = 'rid-fail' + Parse immutable creative opportunity config through a process-global lazy value if compatible with Fastly Compute: - vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')) + ```rust + static CREATIVE_OPPORTUNITIES: std::sync::LazyLock< + trusted_server_core::creative_opportunities::CreativeOpportunitiesFile, + > = std::sync::LazyLock::new(|| { + toml::from_str(CREATIVE_OPPORTUNITIES_TOML) + .expect("should parse creative-opportunities.toml") + }); + ``` - const { installTsAdInit } = await import('./index') - installTsAdInit() - await (window as any).__tsAdInit() + Initialize Fastly `BidCache` through the Task 1 verified Core Cache-backed implementation. Do not use process-global request state for production bid rendezvous. - expect(mockPubads.refresh).toHaveBeenCalled() - }) + The Fastly bid cache itself should be a lightweight value over Core Cache APIs and may be constructed per request because the state lives in Fastly Core Cache, not the Rust object. - it('fires burl via sendBeacon on slotRenderEnded when our bid won', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) - let capturedListener: ((e: any) => void) | undefined +- [ ] **Step 5: Update publisher handler signature** - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('atf'), - getTargeting: vi.fn().mockReturnValue(['abc']), - } - const mockPubads = { - enableSingleRequest: vi.fn(), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: any) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn - }), - } - ;(window as any).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - } - ;(window as any).__ts_ad_slots = [ - { - id: 'atf', - gam_unit_path: '/123/atf', - div_id: 'atf', - formats: [[300, 250]], - targeting: {}, - }, - ] - ;(window as any).__ts_request_id = 'rid-burl-test' - - vi.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - json: async () => ({ - atf: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - burl: 'https://ssp/bill', - }, - }), - } as Response) - - const { installTsAdInit } = await import('./index') - installTsAdInit() - await (window as any).__tsAdInit() - - // Trigger slotRenderEnded — slot has our winning hb_adid - expect(capturedListener).toBeDefined() - capturedListener!({ - isEmpty: false, - slot: mockSlot, - }) - - expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill') - beaconSpy.mockRestore() - }) - }) - ``` - - Run: `cd crates/js/lib && npx vitest run` - Expected: FAIL — `installTsAdInit` not exported or fetches wrong endpoint - -- [ ] **Step 2: Add `installTsAdInit` to `index.ts`** - - Add to `crates/js/lib/src/integrations/gpt/index.ts`: - - ```typescript - interface TsAdSlot { - id: string - gam_unit_path: string - div_id: string - formats: Array - targeting: Record - } + Add the dependencies required by the selected Task 1 shape: + - `orchestrator: &AuctionOrchestrator` + - `slots_file: &CreativeOpportunitiesFile` + - `bid_cache: &BidCache` - interface TsBidData { - hb_pb?: string - hb_bidder?: string - hb_adid?: string - burl?: string - } + Keep `AuctionContext` construction aligned with current code: include `settings`, `request`, `client_info`, `timeout_ms`, `provider_responses`, and `services`. - type TsWindow = Window & { - __ts_ad_slots?: TsAdSlot[] - __ts_request_id?: string - __tsAdInit?: () => void - } +- [ ] **Step 6: Match slots and decide consent before origin body processing** - /** - * Install `window.__tsAdInit`. - * - * Reads `window.__ts_ad_slots` and `window.__ts_request_id` (both injected by - * the edge at `` open). Fetches bid results from `/ts-bids?rid=` - * concurrently with GPT slot definition. Applies targeting and calls `refresh()` - * after the fetch resolves. Registers `slotRenderEnded` to fire `burl` via - * `sendBeacon` when our specific Prebid bid wins the GAM line item match. - */ - export function installTsAdInit(): void { - const w = window as TsWindow - w.__tsAdInit = function () { - const slots = w.__ts_ad_slots ?? [] - const rid = w.__ts_request_id - - const bidsPromise: Promise> = rid - ? fetch(`/ts-bids?rid=${encodeURIComponent(rid)}`, { - credentials: 'omit', - }) - .then((r) => (r.ok ? r.json() : {})) - .catch(() => ({})) - : Promise.resolve({}) - - const g = (window as GptWindow).googletag - if (!g) return - - g.cmd.push(() => { - const gptSlots = slots - .map((slot) => { - const gptSlot = g.defineSlot?.( - slot.gam_unit_path, - slot.formats, - slot.div_id - ) - if (!gptSlot) return null - gptSlot.addService(g.pubads()) - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => - gptSlot.setTargeting(k, v) - ) - return { id: slot.id, gptSlot } - }) - .filter(Boolean) as Array<{ - id: string - gptSlot: NonNullable> - }> - - g.pubads().enableSingleRequest() - g.enableServices() - - bidsPromise.then((bids) => { - gptSlots.forEach(({ id, gptSlot }) => { - const bid = bids[id] ?? {} - ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, bid[key]!) - }) - }) + Required behavior: + - Mint `request_id` only when slots match. + - Match against `req.get_path()`. + - If no slots match, do not register `BidCache`, do not inject globals, and preserve cache headers. + - If slots match but consent is denied/absent, do not run auction and do not inject globals; set browser `Cache-Control: private, no-store`. + - If slots match and consent allows, register pending cache entry with `A_deadline`, inject globals, and dispatch auction through the Task 1 verified non-blocking path. - 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) - } - }) +- [ ] **Step 7: Preserve streaming invariant** - g.pubads().refresh() - }) - }) - } - } - ``` + The implementation must satisfy: + - Origin request is dispatched immediately. + - Page response headers/body are not held for `run_auction`. + - No `wait()` or blocking `select()` for auction work occurs before the page starts streaming. + - During body streaming, auction work may only use non-blocking `poll` calls between chunk writes. + - If the auction completes after page streaming starts, it writes bid results to `BidCache`. - Call `installTsAdInit()` from the integration's initialization path. +- [ ] **Step 8: Force chunked browser response for processed HTML** -- [ ] **Step 3: Run JS tests** + For responses that enter the HTML processing path: + - Remove `Content-Length`. + - Set `Transfer-Encoding: chunked` if Fastly permits it explicitly. + - Do not force chunked on binary pass-through responses. - Run: `cd crates/js/lib && npx vitest run` - Expected: new tests pass +- [ ] **Step 9: Run tests** -- [ ] **Step 4: Build JS bundle** + ```bash + cargo test -p trusted-server-core publisher + cargo test -p trusted-server-adapter-fastly + ``` - Run: `cd crates/js/lib && node build-all.mjs` - Expected: clean build + Expected: tests pass. -- [ ] **Step 5: Commit** +- [ ] **Step 10: Commit** ```bash - git add crates/js/lib/src/integrations/gpt/ - git commit -m "Add installTsAdInit with /ts-bids fetch pattern and slotRenderEnded burl firing" + git add crates/trusted-server-core/src/platform/http.rs crates/trusted-server-adapter-fastly/src/platform.rs \ + crates/trusted-server-core/src/auction/orchestrator.rs crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-adapter-fastly/src/main.rs + git commit -m "Start ad template auctions without blocking HTML streaming" ``` --- -## Task 13: `nurl` fire-and-forget +## Task 13: Server-Side `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** +This task implements `nurl` firing only for the Fastly Phase 1 path. Future adapters may return unsupported or disable server-side ad templates until they can provide equivalent non-blocking outbound HTTP behavior. + +- [ ] **Step 1: Write failing config test** + + In Prebid tests, assert: ```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"); + fn prebid_fire_nurl_at_edge_defaults_to_true() { + let config = PrebidIntegrationConfig { + fire_nurl_at_edge: true, + ..base_config() + }; + assert!(config.fire_nurl_at_edge, "should default to edge nurl firing"); } ``` - Run: `cargo test -p trusted-server-core integrations::prebid` - Expected: FAIL + Adjust to the existing test helper style in `prebid.rs`. -- [ ] **Step 2: Add `fire_nurl_at_edge` to `PrebidConfig`** + Run: - ```rust - #[serde(default = "default_fire_nurl_at_edge")] - pub fire_nurl_at_edge: bool, + ```bash + cargo test -p trusted-server-core integrations::prebid ``` - ```rust - fn default_fire_nurl_at_edge() -> bool { true } - ``` + Expected: failure until field/default is implemented. -- [ ] **Step 3: Fire nurls in publisher.rs after bid_cache.put()** +- [ ] **Step 2: Add config field** - After the `bid_cache.put(...)` call (Task 9 Step 3), add: + Add to `PrebidIntegrationConfig`: ```rust - if let Some(ref result) = auction_result { - fire_winning_nurls(result, settings); - } + #[serde(default = "default_fire_nurl_at_edge")] + pub fire_nurl_at_edge: bool, ``` - Add helper: + Add: ```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), - } - } + fn default_fire_nurl_at_edge() -> bool { + true } ``` +- [ ] **Step 3: Fire winning nurls after auction completion** + + In the selected non-blocking auction completion path, after writing bid results to `BidCache`, call a helper that: + - Reads `PrebidIntegrationConfig` via `settings.integrations.get_typed::("prebid")`. + - Defaults to `true` if config is absent. + - Uses the Fastly-supported async HTTP primitive from Task 1, for example `fastly::Request::get(nurl).send_async(&backend_name)`, for each winning bid with `nurl`. + - Logs warnings but never fails the page or `/ts-bids`. + - [ ] **Step 4: Run tests** - Run: `cargo test --workspace` - Expected: all pass + ```bash + cargo test -p trusted-server-core integrations::prebid publisher + ``` + + Expected: tests 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" + git add crates/trusted-server-core/src/integrations/prebid.rs crates/trusted-server-core/src/publisher.rs + git commit -m "Fire Prebid nurls from the edge" ``` --- -## Task 14: End-to-end integration tests +## Task 14: End-to-End Verification **Files:** -- Modify: `crates/trusted-server-core/src/publisher.rs` (test module) +- Modify only if needed by test fixes. -Tests use `pub(crate)` helpers from Task 9 directly. +- [ ] **Step 1: Run Rust tests** -- [ ] **Step 1: Write tests** + ```bash + cargo test --workspace + ``` - In `publisher.rs` test module: + Expected: all tests pass. - ```rust - #[cfg(test)] - mod creative_opportunities_tests { - use super::{build_head_globals_script, build_bid_map, html_escape_for_script}; - use crate::creative_opportunities::{ - CreativeOpportunitiesConfig, CreativeOpportunitySlot, CreativeOpportunityFormat, - CreativeOpportunitiesFile, match_slots, - }; - use crate::auction::types::{Bid, MediaType}; - use crate::price_bucket::PriceGranularity; - use std::collections::HashMap; - - fn make_config() -> CreativeOpportunitiesConfig { - CreativeOpportunitiesConfig { - gam_network_id: "21765378893".to_string(), - auction_timeout_ms: Some(500), - price_granularity: PriceGranularity::Dense, - } - } +- [ ] **Step 2: Run Rust formatting check** - 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(), - } - } + ```bash + cargo fmt --all -- --check + ``` - #[test] - fn head_globals_script_contains_ad_slots_and_request_id() { - let slots = vec![make_slot()]; - let config = make_config(); - let rid = "550e8400-e29b-41d4-a716-446655440000"; - let script = build_head_globals_script(&slots, rid, &config); - assert!(script.contains("window.__ts_ad_slots=JSON.parse"), "should use JSON.parse for slots"); - assert!(script.contains("atf_sidebar_ad"), "should include slot id"); - assert!(script.contains(&format!("window.__ts_request_id=\"{rid}\"")), "should include request_id"); - assert!(!script.contains("__ts_bids"), "must NOT contain bids — bids come from /ts-bids"); - } + Expected: no formatting changes needed. - #[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"); - } +- [ ] **Step 3: Run Clippy** - #[test] - fn bid_map_uses_price_bucket_and_ad_id() { - let mut winning_bids = HashMap::new(); - winning_bids.insert("atf_sidebar_ad".to_string(), Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(2.53), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "kargo".to_string(), - width: 300, height: 250, - nurl: None, - burl: Some("https://ssp.example/billing?id=abc123".to_string()), - ad_id: Some("prebid-uuid-abc123".to_string()), - metadata: HashMap::new(), - }); - let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); - let slot_bids = bid_map.get("atf_sidebar_ad").expect("should have slot bids"); - assert_eq!( - slot_bids.get("hb_pb").and_then(|v| v.as_str()), - Some("2.53"), - "should bucket 2.53 as 2.53 (dense)" - ); - assert_eq!( - slot_bids.get("hb_bidder").and_then(|v| v.as_str()), - Some("kargo"), - "should include bidder" - ); - assert_eq!( - slot_bids.get("hb_adid").and_then(|v| v.as_str()), - Some("prebid-uuid-abc123"), - "should use ad_id not creative markup" - ); - } + ```bash + cargo clippy --workspace --all-targets --all-features -- -D warnings + ``` - #[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 >"); - } + Expected: no warnings. - #[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 4: Run JS tests and build** + + ```bash + cd crates/js/lib && npx vitest run + cd crates/js/lib && node build-all.mjs ``` -- [ ] **Step 2: Run tests** + Expected: tests and build pass. - Run: `cargo test -p trusted-server-core creative_opportunities_tests` - Expected: all pass +- [ ] **Step 5: Run manual Fastly verification** -- [ ] **Step 3: Run full suite + CI gates** + Run: ```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 + fastly compute serve ``` - Expected: all clean + In another shell, use the local server from `fastly.toml`: -- [ ] **Step 4: Commit** + ```bash + curl -i http://127.0.0.1:7676/about + curl -i http://127.0.0.1:7676/2024/01/test-article/ + curl -i "http://127.0.0.1:7676/ts-bids" + curl -i "http://127.0.0.1:7676/ts-bids?rid=not-real" + ``` + + For a matched page with valid consent, copy the `window.__ts_request_id` value from the HTML and run: ```bash - git add crates/trusted-server-core/src/publisher.rs - git commit -m "Add integration tests for creative opportunities pipeline (head globals, bid map, XSS)" + curl -i "http://127.0.0.1:7676/ts-bids?rid=" ``` ---- + Verify with curl output: + - `/about`: no `__ts_ad_slots`, no `__ts_request_id`, no TS-added `Cache-Control: private, no-store`. + - matched URL with valid consent: contains `__ts_ad_slots` and `__ts_request_id` at `` open, no `__ts_bids`, browser-facing `Cache-Control: private, no-store`. + - matched URL with denied/missing consent: no ad globals and browser-facing `Cache-Control: private, no-store`. + - `Surrogate-Control` and `Fastly-Surrogate-Control` from origin are preserved. + - `/ts-bids?rid=` returns JSON, `Content-Type: application/json`, `Cache-Control: private, no-store`. + - `/ts-bids` without `rid` returns `400`. + - `/ts-bids?rid=not-real` returns `404`. + - First HTML bytes arrive before a delayed auction completes, using the evidence path from Task 1. + +- [ ] **Step 6: Run browser verification** -## Manual Verification Checklist + With `fastly compute serve` still running, verify manually in Chrome or with Chrome MCP: + - Open `http://127.0.0.1:7676/2024/01/test-article/`. + - Evaluate `window.__ts_ad_slots` and `window.__ts_request_id`; both should exist only on matched, consent-allowed pages. + - Evaluate `window.__ts_bids`; it should be `undefined`. + - Inspect the Network panel and confirm the page issues a single `/ts-bids?rid=` request. + - Confirm the console has no GPT bootstrap errors. + - Confirm no ad globals exist on `/about` or matched pages with denied/missing consent. -Run `fastly compute serve` and verify: +- [ ] **Step 7: Commit any final test/documentation fixes** -- [ ] **No match:** Request `/about` — no `__ts_ad_slots`, no `__ts_request_id` in response HTML; no `Cache-Control: private, no-store` -- [ ] **Match:** Request `/2024/01/article` — `window.__ts_ad_slots` and `window.__ts_request_id` in ``; `Cache-Control: private, no-store`; **no `__ts_bids` in HTML** -- [ ] **`/ts-bids` cache hit:** Request `/2024/01/article`, then `GET /ts-bids?rid=` — returns JSON within 30ms; `Content-Type: application/json`; `Cache-Control: private, no-store` -- [ ] **`/ts-bids` unknown rid:** `GET /ts-bids?rid=not-a-real-id` — returns 404 -- [ ] **`/ts-bids` missing rid:** `GET /ts-bids` — returns 400 -- [ ] **Empty file kill-switch:** Empty `creative-opportunities.toml` → no globals injected on any URL; no cache headers -- [ ] **Auction timeout:** Set `auction_timeout_ms = 1` → `/ts-bids` returns `{}` promptly -- [ ] **XSS check:** Add `targeting = { zone = ""#.to_string()] + } + } + + let html = r#"Test"#; + + let mut config = create_test_config(); + config.ad_slots_script = Some( + r#""# + .to_string(), + ); + config.integrations = IntegrationRegistry::from_rewriters_with_head_injectors( + Vec::new(), + Vec::new(), + vec![Arc::new(TestHeadInjector)], + ); + + let processor = create_html_processor(config); + let pipeline_config = PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 8192, + }; + let mut pipeline = StreamingPipeline::new(pipeline_config, processor); + + let mut output = Vec::new(); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("pipeline should process HTML"); + let processed = String::from_utf8(output).expect("output should be valid UTF-8"); + + let ad_slots_index = processed + .find("window.__ts_ad_slots") + .expect("should inject ad slots global"); + let request_id_index = processed + .find("window.__ts_request_id") + .expect("should inject request ID global"); + let head_insert_index = processed + .find("window.__testHeadInjector=true") + .expect("should inject integration head insert"); + let tsjs_index = processed + .find("id=\"trustedserver-js\"") + .expect("should inject unified tsjs tag"); + let title_index = processed + .find("") + .expect("should keep existing head content"); + + assert_eq!( + processed.matches("window.__ts_ad_slots").count(), + 1, + "should inject ad slots global once" + ); + assert_eq!( + processed.matches("window.__ts_request_id").count(), + 1, + "should inject request ID global once" + ); + assert!( + ad_slots_index < head_insert_index, + "ad slots script should be first injected head content" + ); + assert!( + request_id_index < head_insert_index, + "request ID should be part of the first injected head content" + ); + assert!( + head_insert_index < tsjs_index, + "integration head inserts should remain before tsjs" + ); + assert!( + tsjs_index < title_index, + "all injected content should be prepended at the head open tag" + ); + assert!( + !processed.contains("window.__ts_bids"), + "HTML output must never include bid globals" + ); + } + #[test] fn test_create_html_processor_url_replacement() { let config = create_test_config(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5bcef6941..1170a62e9 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -21,6 +21,7 @@ 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}; use crate::cookies::{expire_ec_cookie, handle_request_cookies, set_ec_cookie}; +use crate::creative_opportunities::{CreativeOpportunitiesConfig, CreativeOpportunitySlot}; use crate::edge_cookie::get_or_generate_ec_id; use crate::error::TrustedServerError; use crate::http_util::{serve_static_with_etag, RequestInfo}; @@ -172,6 +173,66 @@ fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { .find(|&id| id == stem) } +/// Build the head script that exposes server-side ad slot metadata. +#[must_use] +pub(crate) fn build_head_globals_script( + matched_slots: &[&CreativeOpportunitySlot], + request_id: &str, + co_config: &CreativeOpportunitiesConfig, +) -> String { + #[derive(serde::Serialize)] + struct HeadAdSlot<'a> { + id: &'a str, + gam_unit_path: String, + div_id: String, + formats: Vec<[u32; 2]>, + targeting: &'a std::collections::HashMap<String, String>, + } + + let slots = matched_slots + .iter() + .map(|slot| HeadAdSlot { + id: &slot.id, + gam_unit_path: slot.resolved_gam_unit_path(co_config), + div_id: slot.resolved_div_id(), + formats: slot + .formats + .iter() + .map(|format| [format.width, format.height]) + .collect(), + targeting: &slot.targeting, + }) + .collect::<Vec<_>>(); + + let slots_json = serde_json::to_string(&slots).expect("should serialize ad slots"); + let request_id_json = serde_json::to_string(request_id).expect("should serialize request ID"); + let escaped_slots_json = html_escape_for_script(&slots_json); + let escaped_request_id_json = html_escape_for_script(&request_id_json); + + format!( + "<script>window.__ts_ad_slots=JSON.parse(\"{escaped_slots_json}\");window.__ts_request_id=JSON.parse(\"{escaped_request_id_json}\");</script>" + ) +} + +/// Escape JSON so it can be embedded in a JavaScript string inside an HTML script. +#[must_use] +pub(crate) fn html_escape_for_script(json: &str) -> String { + let mut escaped = String::with_capacity(json.len()); + for ch in json.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '<' => escaped.push_str("\\u003C"), + '>' => escaped.push_str("\\u003E"), + '&' => escaped.push_str("\\u0026"), + '\u{2028}' => escaped.push_str("\\u2028"), + '\u{2029}' => escaped.push_str("\\u2029"), + _ => escaped.push(ch), + } + } + escaped +} + /// Parameters for processing response streaming struct ProcessResponseParams<'a> { content_encoding: &'a str, @@ -719,10 +780,50 @@ fn apply_ec_headers( #[cfg(test)] mod tests { use super::*; + use crate::auction::types::MediaType; + use crate::creative_opportunities::{ + CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, + SlotProviders, + }; use crate::integrations::IntegrationRegistry; use crate::platform::test_support::noop_services; + use crate::price_bucket::PriceGranularity; use crate::test_support::tests::create_test_settings; use fastly::http::{header, Method, StatusCode}; + use serde_json::json; + use std::collections::HashMap; + + fn make_creative_slot(id: &str) -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: id.to_string(), + gam_unit_path: Some(format!("/21765378893/{id}")), + div_id: Some(format!("div-{id}")), + page_patterns: vec!["/news/**".to_string()], + formats: vec![ + CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + }, + CreativeOpportunityFormat { + width: 728, + height: 90, + media_type: MediaType::Banner, + }, + ], + floor_price: Some(1.25), + targeting: HashMap::from([("pos".to_string(), "atf".to_string())]), + providers: SlotProviders::default(), + } + } + + fn make_creative_config() -> CreativeOpportunitiesConfig { + CreativeOpportunitiesConfig { + gam_network_id: "21765378893".to_string(), + auction_timeout_ms: Some(500), + price_granularity: PriceGranularity::Dense, + } + } #[test] fn test_content_type_detection() { @@ -751,6 +852,84 @@ mod tests { } } + #[test] + fn build_head_globals_script_sets_slots_and_request_id_only() { + let slot = make_creative_slot("atf_sidebar"); + let slots = vec![&slot]; + + let script = build_head_globals_script(&slots, "req-123", &make_creative_config()); + + assert!( + script.contains("window.__ts_ad_slots"), + "should set ad slots global" + ); + assert!( + script.contains("window.__ts_request_id"), + "should set request ID global" + ); + assert!( + script.contains("JSON.parse(\""), + "should parse escaped JSON from a string literal" + ); + assert!( + script.contains("\\\"id\\\":\\\"atf_sidebar\\\""), + "should include slot ID in escaped JSON" + ); + assert!( + script.contains("\\\"gam_unit_path\\\":\\\"/21765378893/atf_sidebar\\\""), + "should include resolved GAM unit path" + ); + assert!( + script.contains("\\\"formats\\\":[[300,250],[728,90]]"), + "should include slot formats" + ); + assert!( + script.contains("\\\"targeting\\\":{\\\"pos\\\":\\\"atf\\\"}"), + "should include static targeting" + ); + assert!( + script.contains("JSON.parse(\"\\\"req-123\\\"\")"), + "should set request ID through JSON.parse" + ); + assert!( + !script.contains("window.__ts_bids"), + "head globals must not inject bid data" + ); + } + + #[test] + fn html_escape_for_script_prevents_raw_script_breakout() { + let unsafe_json = serde_json::to_string(&json!({ + "slot": "</script><script>alert(\"x\")</script>", + "ampersand": "a&b", + "line": "first\u{2028}second\u{2029}third", + })) + .expect("should serialize unsafe JSON fixture"); + + let escaped = html_escape_for_script(&unsafe_json); + + assert!( + !escaped.contains("</script>"), + "escaped JSON string should not contain a raw script end tag" + ); + assert!( + !escaped.contains("<script>"), + "escaped JSON string should not contain a raw script start tag" + ); + assert!( + escaped.contains("\\u003C/script\\u003E"), + "less-than and greater-than should be escaped for HTML script safety" + ); + assert!( + escaped.contains("\\u0026"), + "ampersands should be escaped for HTML safety" + ); + assert!( + escaped.contains("\\u2028") && escaped.contains("\\u2029"), + "line separators should be escaped for JavaScript string safety" + ); + } + #[test] fn supported_content_encoding_accepts_known_values() { assert!(is_supported_content_encoding(""), "should accept empty"); From 21a28643ac89c930d89c6a6a9bc94490aa6231ea Mon Sep 17 00:00:00 2001 From: prk-Jr <prakashsagwara@gmail.com> Date: Sun, 3 May 2026 17:19:03 +0530 Subject: [PATCH 15/24] Add publisher helpers for ad template responses --- crates/trusted-server-core/src/bid_cache.rs | 5 + crates/trusted-server-core/src/publisher.rs | 264 +++++++++++++++++++- 2 files changed, 265 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/bid_cache.rs b/crates/trusted-server-core/src/bid_cache.rs index e16babb06..6ad25d371 100644 --- a/crates/trusted-server-core/src/bid_cache.rs +++ b/crates/trusted-server-core/src/bid_cache.rs @@ -41,6 +41,7 @@ pub struct AuctionDeadline { impl AuctionDeadline { /// Builds an [`AuctionDeadline`] from explicit local and absolute times. + #[must_use] pub fn from_parts(instant: Instant, epoch_ms: u64) -> Self { Self { instant, epoch_ms } } @@ -49,6 +50,7 @@ impl AuctionDeadline { /// /// This computes both the monotonic [`Instant`] and Unix epoch millisecond /// deadline once so later paths can reuse the same timeout. + #[must_use] pub fn from_timeout(timeout: Duration) -> Self { let now_instant = Instant::now(); let now_epoch = SystemTime::now(); @@ -67,6 +69,7 @@ impl AuctionDeadline { /// Fastly stores only the epoch deadline in Core Cache. A later `/ts-bids` /// request uses this helper to enforce that original absolute deadline /// without minting a fresh timeout. + #[must_use] pub fn from_epoch_ms(epoch_ms: u64) -> Option<Self> { Self::from_epoch_ms_with_max_remaining(epoch_ms, DEFAULT_MAX_RECONSTRUCTED_WAIT) } @@ -76,6 +79,7 @@ impl AuctionDeadline { /// /// Returns [`None`] when the persisted deadline is implausibly far in the /// future or cannot be represented locally. + #[must_use] pub fn from_epoch_ms_with_max_remaining( epoch_ms: u64, max_remaining: Duration, @@ -203,6 +207,7 @@ struct StoredBidCacheEntry { impl InMemoryBidCache { /// Creates an in-memory bid cache with a TTL and maximum capacity. + #[must_use] pub fn new(ttl: Duration, capacity: usize) -> Self { Self { ttl, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1170a62e9..01dcf7ee3 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -174,6 +174,7 @@ fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { } /// Build the head script that exposes server-side ad slot metadata. +#[allow(dead_code)] #[must_use] pub(crate) fn build_head_globals_script( matched_slots: &[&CreativeOpportunitySlot], @@ -215,6 +216,7 @@ pub(crate) fn build_head_globals_script( } /// Escape JSON so it can be embedded in a JavaScript string inside an HTML script. +#[allow(dead_code)] #[must_use] pub(crate) fn html_escape_for_script(json: &str) -> String { let mut escaped = String::with_capacity(json.len()); @@ -233,6 +235,52 @@ pub(crate) fn html_escape_for_script(json: &str) -> String { escaped } +#[allow(dead_code)] +#[must_use] +pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap<String, crate::auction::types::Bid>, + price_granularity: crate::price_bucket::PriceGranularity, +) -> crate::bid_cache::BidMap { + winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + let cpm = bid.price?; + Some(( + slot_id.clone(), + serde_json::json!({ + "hb_pb": crate::price_bucket::price_bucket(cpm, price_granularity), + "hb_bidder": bid.bidder.as_str(), + "hb_adid": bid.ad_id.as_deref().unwrap_or(""), + "burl": bid.burl.as_deref(), + }), + )) + }) + .collect() +} + +#[allow(dead_code)] +fn server_side_auction_allowed(consent_context: &crate::consent::ConsentContext) -> bool { + consent_context + .tcf + .as_ref() + .is_some_and(|tcf| tcf.has_purpose_consent(1)) +} + +#[allow(dead_code)] +fn apply_server_side_ad_cache_policy( + response: &mut Response, + slots_matched: bool, + globals_injected: bool, +) { + if !slots_matched { + return; + } + + if globals_injected || slots_matched { + response.set_header(header::CACHE_CONTROL, "private, no-store"); + } +} + /// Parameters for processing response streaming struct ProcessResponseParams<'a> { content_encoding: &'a str, @@ -778,9 +826,10 @@ fn apply_ec_headers( } #[cfg(test)] -mod tests { +mod creative_opportunities_tests { use super::*; - use crate::auction::types::MediaType; + use crate::auction::types::{Bid, MediaType}; + use crate::consent::{ConsentContext, TcfConsent}; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, SlotProviders, @@ -825,6 +874,42 @@ mod tests { } } + fn make_bid(slot_id: &str, price: Option<f64>, ad_id: Option<&str>) -> Bid { + Bid { + slot_id: slot_id.to_string(), + price, + currency: "USD".to_string(), + creative: Some("<div>ad</div>".to_string()), + adomain: Some(vec!["advertiser.example".to_string()]), + bidder: "rubicon".to_string(), + width: 300, + height: 250, + nurl: Some("https://bidder.example/win".to_string()), + burl: Some("https://bidder.example/bill".to_string()), + ad_id: ad_id.map(str::to_string), + metadata: HashMap::new(), + } + } + + fn make_tcf_with_purpose_1(consented: bool) -> TcfConsent { + TcfConsent { + version: 2, + cmp_id: 1, + cmp_version: 1, + consent_screen: 1, + consent_language: "EN".to_string(), + vendor_list_version: 42, + tcf_policy_version: 4, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![consented, true, true], + purpose_legitimate_interests: vec![false; 3], + vendor_consents: vec![], + vendor_legitimate_interests: vec![], + special_feature_opt_ins: vec![], + } + } + #[test] fn test_content_type_detection() { // Test which content types should be processed @@ -901,6 +986,7 @@ mod tests { fn html_escape_for_script_prevents_raw_script_breakout() { let unsafe_json = serde_json::to_string(&json!({ "slot": "</script><script>alert(\"x\")</script>", + "angle": "<tag>", "ampersand": "a&b", "line": "first\u{2028}second\u{2029}third", })) @@ -913,8 +999,16 @@ mod tests { "escaped JSON string should not contain a raw script end tag" ); assert!( - !escaped.contains("<script>"), - "escaped JSON string should not contain a raw script start tag" + !escaped.contains('<') && !escaped.contains('>'), + "less-than and greater-than should be escaped for HTML script safety" + ); + assert!( + !escaped.contains('&'), + "ampersands should be escaped for HTML script safety" + ); + assert!( + !escaped.contains('\u{2028}') && !escaped.contains('\u{2029}'), + "line separators should be escaped for JavaScript string safety" ); assert!( escaped.contains("\\u003C/script\\u003E"), @@ -930,6 +1024,168 @@ mod tests { ); } + #[test] + fn build_bid_map_emits_prebid_targeting_fields() { + let winning_bids = HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]); + + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + + assert_eq!( + bid_map.get("atf_sidebar"), + Some(&json!({ + "hb_pb": "1.23", + "hb_bidder": "rubicon", + "hb_adid": "ad-123", + "burl": "https://bidder.example/bill", + })), + "should emit Prebid targeting fields for priced bids" + ); + } + + #[test] + fn build_bid_map_uses_empty_ad_id_when_missing() { + let winning_bids = HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.0), None), + )]); + + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + + assert_eq!( + bid_map + .get("atf_sidebar") + .and_then(|bid| bid.get("hb_adid")), + Some(&json!("")), + "should use empty hb_adid when the provider did not return one" + ); + } + + #[test] + fn build_bid_map_omits_bids_without_price() { + let winning_bids = HashMap::from([ + ( + "priced".to_string(), + make_bid("priced", Some(2.0), Some("ad-priced")), + ), + ("aps".to_string(), make_bid("aps", None, Some("ad-aps"))), + ]); + + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + + assert!( + bid_map.contains_key("priced"), + "should keep bids with a price" + ); + assert!( + !bid_map.contains_key("aps"), + "should omit bids whose price must be decoded elsewhere" + ); + } + + #[test] + fn server_side_auction_allowed_requires_tcf_purpose_1_consent() { + let no_tcf = ConsentContext::default(); + let denied = ConsentContext { + tcf: Some(make_tcf_with_purpose_1(false)), + ..ConsentContext::default() + }; + let allowed = ConsentContext { + tcf: Some(make_tcf_with_purpose_1(true)), + ..ConsentContext::default() + }; + + assert!( + !server_side_auction_allowed(&no_tcf), + "absent TCF should deny server-side auction" + ); + assert!( + !server_side_auction_allowed(&denied), + "TCF without Purpose 1 consent should deny server-side auction" + ); + assert!( + server_side_auction_allowed(&allowed), + "TCF with Purpose 1 consent should allow server-side auction" + ); + } + + #[test] + fn cache_policy_sets_no_store_when_slots_matched_and_consent_denied() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + + apply_server_side_ad_cache_policy(&mut response, true, false); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "matched slots without injected globals should not be browser cached" + ); + } + + #[test] + fn cache_policy_sets_no_store_when_globals_are_injected() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + + apply_server_side_ad_cache_policy(&mut response, true, true); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "responses with request-scoped globals should not be browser cached" + ); + } + + #[test] + fn cache_policy_preserves_surrogate_headers() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + response.set_header("Surrogate-Control", "max-age=3600"); + response.set_header("Fastly-Surrogate-Control", "max-age=7200"); + + apply_server_side_ad_cache_policy(&mut response, true, true); + + assert_eq!( + response.get_header_str("Surrogate-Control"), + Some("max-age=3600"), + "should preserve origin Surrogate-Control" + ); + assert_eq!( + response.get_header_str("Fastly-Surrogate-Control"), + Some("max-age=7200"), + "should preserve origin Fastly-Surrogate-Control" + ); + } + + #[test] + fn cache_policy_preserves_origin_cache_headers_when_no_slots_match() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + response.set_header("Surrogate-Control", "max-age=3600"); + response.set_header("Fastly-Surrogate-Control", "max-age=7200"); + + apply_server_side_ad_cache_policy(&mut response, false, false); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("public, max-age=300"), + "no-match responses should preserve browser cache headers" + ); + assert_eq!( + response.get_header_str("Surrogate-Control"), + Some("max-age=3600"), + "no-match responses should preserve Surrogate-Control" + ); + assert_eq!( + response.get_header_str("Fastly-Surrogate-Control"), + Some("max-age=7200"), + "no-match responses should preserve Fastly-Surrogate-Control" + ); + } + #[test] fn supported_content_encoding_accepts_known_values() { assert!(is_supported_content_encoding(""), "should accept empty"); From 87e7f7ab3f535b846ac15cdc2a600f241f23aa61 Mon Sep 17 00:00:00 2001 From: prk-Jr <prakashsagwara@gmail.com> Date: Sun, 3 May 2026 18:50:51 +0530 Subject: [PATCH 16/24] Add GPT bid fetch bootstrap --- crates/js/lib/src/integrations/gpt/index.ts | 189 ++++++++++++- .../lib/test/integrations/gpt/index.test.ts | 255 ++++++++++++++++++ .../src/integrations/gpt.rs | 62 ++++- 3 files changed, 498 insertions(+), 8 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 0b9e30235..2ac58ac1f 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -31,12 +31,19 @@ interface GoogleTagSlot { getAdUnitPath(): string; getSlotElementId(): string; setTargeting(key: string, value: string | string[]): GoogleTagSlot; + getTargeting(key: string): string[]; + addService(service: GoogleTagPubAdsService): GoogleTagSlot; } interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; + addEventListener( + eventName: 'slotRenderEnded', + callback: (event: SlotRenderEndedEvent) => void + ): void; + refresh(slots?: GoogleTagSlot[]): void; } interface GoogleTag { @@ -54,6 +61,38 @@ interface GoogleTag { type GptWindow = Window & { googletag?: Partial<GoogleTag>; + __ts_ad_slots?: TsAdSlot[]; + __ts_request_id?: string; + __tsAdInit?: () => boolean; + __tsAdInitInstalled?: boolean; +}; + +type TsAdSlot = { + id?: string; + gam_unit_path?: string; + div_id?: string; + formats?: Array<number | number[]>; + targeting?: Record<string, string | string[]>; +}; + +type TsBidTargeting = { + hb_pb?: string; + hb_bidder?: string; + hb_adid?: string; + burl?: string; +}; + +type TsBidMap = Record<string, TsBidTargeting | undefined>; + +type DefinedTsSlot = { + descriptor: TsAdSlot; + slot: GoogleTagSlot; +}; + +type SlotRenderEndedEvent = { + slot?: { + getTargeting?: (key: string) => string[]; + }; }; // ------------------------------------------------------------------ @@ -137,6 +176,152 @@ function patchCommandQueue(tag: Partial<GoogleTag>): void { log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); } +function readTsAdSlots(win: GptWindow): TsAdSlot[] { + return Array.isArray(win.__ts_ad_slots) ? win.__ts_ad_slots : []; +} + +function fetchTsBids(win: GptWindow): Promise<TsBidMap> { + const rid = win.__ts_request_id; + if (!rid || typeof fetch !== 'function') { + return Promise.resolve({}); + } + + return fetch(`/ts-bids?rid=${encodeURIComponent(rid)}`, { credentials: 'omit' }) + .then((response) => response.json() as Promise<TsBidMap>) + .catch(() => ({})); +} + +function applyStaticTargeting(slot: GoogleTagSlot, targeting: TsAdSlot['targeting']): void { + for (const [key, value] of Object.entries(targeting ?? {})) { + slot.setTargeting(key, value); + } +} + +function applyBidTargeting(slot: GoogleTagSlot, bid: TsBidTargeting): void { + for (const key of ['hb_pb', 'hb_bidder', 'hb_adid'] as const) { + const value = bid[key]; + if (value != null) { + slot.setTargeting(key, String(value)); + } + } +} + +function installBurlListener( + pubads: GoogleTagPubAdsService, + bidsByAdId: Map<string, TsBidTargeting> +): void { + if (typeof pubads.addEventListener !== 'function') { + return; + } + + pubads.addEventListener('slotRenderEnded', (event) => { + const hbAdIds = event.slot?.getTargeting?.('hb_adid') ?? []; + const hbAdId = hbAdIds[0]; + const bid = hbAdId ? bidsByAdId.get(hbAdId) : undefined; + + if ( + !bid?.burl || + typeof navigator === 'undefined' || + typeof navigator.sendBeacon !== 'function' + ) { + return; + } + + navigator.sendBeacon(bid.burl); + bidsByAdId.delete(hbAdId); + }); +} + +function runTsAdInit(win: GptWindow): void { + const tag = win.googletag as GoogleTag | undefined; + const bidsPromise = fetchTsBids(win); + const slots = readTsAdSlots(win); + const definedSlots: DefinedTsSlot[] = []; + const bidsByAdId = new Map<string, TsBidTargeting>(); + + if ( + !tag || + typeof tag.defineSlot !== 'function' || + typeof tag.pubads !== 'function' || + typeof tag.enableServices !== 'function' + ) { + return; + } + + const pubads = tag.pubads(); + installBurlListener(pubads, bidsByAdId); + + for (const descriptor of slots) { + if (!descriptor.gam_unit_path || !descriptor.div_id || !descriptor.id) { + continue; + } + + const slot = tag.defineSlot( + descriptor.gam_unit_path, + descriptor.formats ?? [], + descriptor.div_id + ); + if (!slot) { + continue; + } + + if (typeof slot.addService === 'function') { + slot.addService(pubads); + } + applyStaticTargeting(slot, descriptor.targeting); + definedSlots.push({ descriptor, slot }); + } + + tag.enableServices(); + + for (const { descriptor } of definedSlots) { + if (typeof tag.display === 'function') { + tag.display(descriptor.div_id as string); + } + } + + bidsPromise.then((bids) => { + for (const { descriptor, slot } of definedSlots) { + const bid = bids[descriptor.id as string]; + if (!bid) { + continue; + } + + applyBidTargeting(slot, bid); + if (bid.hb_adid) { + bidsByAdId.set(String(bid.hb_adid), bid); + } + } + + if (typeof pubads.refresh === 'function') { + pubads.refresh(definedSlots.map(({ slot }) => slot)); + } + }); +} + +/** + * Install the Trusted Server ad bootstrap for GPT slots. + * + * The bootstrap reads `window.__ts_ad_slots` and `window.__ts_request_id`, + * defines GPT slots immediately, then applies server-side bid targeting from + * `/ts-bids` before refreshing the slots. + */ +export function installTsAdInit(): boolean { + if (typeof window === 'undefined') { + return false; + } + + const win = window as GptWindow; + if (win.__tsAdInitInstalled) { + return true; + } + + win.__tsAdInitInstalled = true; + const tag = ensureGoogleTagStub(win); + tag.cmd!.push(() => runTsAdInit(win)); + return true; +} + /** * Install the GPT integration shim. * @@ -156,6 +341,7 @@ export function installGptShim(): boolean { const tag = ensureGoogleTagStub(win); patchCommandQueue(tag); + installTsAdInit(); log.info('GPT shim installed'); return true; @@ -170,9 +356,10 @@ export function installGptShim(): boolean { // 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<string, unknown>; + const win = window as unknown as Record<string, unknown>; win.__tsjs_installGptShim = installGptShim; + win.__tsAdInit = installTsAdInit; if (win.__tsjs_gpt_enabled === true) { installGptShim(); diff --git a/crates/js/lib/test/integrations/gpt/index.test.ts b/crates/js/lib/test/integrations/gpt/index.test.ts index 57c4015dc..8685ba2bc 100644 --- a/crates/js/lib/test/integrations/gpt/index.test.ts +++ b/crates/js/lib/test/integrations/gpt/index.test.ts @@ -17,6 +17,91 @@ type GptWindow = Window & { }; }; +type TsAdInitWindow = Window & { + __ts_ad_slots?: Array<Record<string, unknown>>; + __ts_request_id?: string; + googletag?: { + cmd: Array<() => void>; + defineSlot: ReturnType<typeof vi.fn>; + pubads: ReturnType<typeof vi.fn>; + enableServices: ReturnType<typeof vi.fn>; + display: ReturnType<typeof vi.fn>; + }; +}; + +type MockSlot = { + id: string; + targeting: Map<string, string[]>; + setTargeting: ReturnType<typeof vi.fn>; + getTargeting: ReturnType<typeof vi.fn>; + addService: ReturnType<typeof vi.fn>; +}; + +const flushPromises = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +function jsonResponse(body: unknown) { + return { + json: () => Promise.resolve(body), + }; +} + +function createGptHarness(win: TsAdInitWindow) { + const operations: string[] = []; + const slots: MockSlot[] = []; + let slotRenderEnded: ((event: { slot: MockSlot }) => void) | undefined; + + const pubadsService = { + addEventListener: vi.fn((eventName: string, callback: (event: { slot: MockSlot }) => void) => { + if (eventName === 'slotRenderEnded') { + slotRenderEnded = callback; + } + }), + refresh: vi.fn(() => { + operations.push('refresh'); + }), + }; + + win.googletag = { + cmd: [], + defineSlot: vi.fn((_adUnitPath: string, _sizes: unknown, elementId: string) => { + const targeting = new Map<string, string[]>(); + const slot: MockSlot = { + id: elementId, + targeting, + setTargeting: vi.fn((key: string, value: string | string[]) => { + const values = Array.isArray(value) ? value : [value]; + targeting.set(key, values); + operations.push(`set:${elementId}:${key}:${values.join(',')}`); + return slot; + }), + getTargeting: vi.fn((key: string) => targeting.get(key) ?? []), + addService: vi.fn(() => slot), + }; + slots.push(slot); + operations.push(`define:${elementId}`); + return slot; + }), + pubads: vi.fn(() => pubadsService), + enableServices: vi.fn(() => { + operations.push('enableServices'); + }), + display: vi.fn((elementId: string) => { + operations.push(`display:${elementId}`); + }), + }; + + return { + operations, + pubadsService, + slots, + triggerSlotRenderEnded: (slot: MockSlot) => slotRenderEnded?.({ slot }), + }; +} + describe('GPT shim – patchCommandQueue', () => { let win: GptWindow; let installGptShim: () => boolean; @@ -165,6 +250,176 @@ describe('GPT shim – patchCommandQueue', () => { }); }); +describe('GPT shim – __tsAdInit bootstrap', () => { + let win: TsAdInitWindow; + let installTsAdInit: () => boolean; + let originalFetch: typeof globalThis.fetch; + let originalSendBeacon: typeof navigator.sendBeacon; + + beforeEach(async () => { + vi.resetModules(); + win = window as TsAdInitWindow; + delete win.__ts_ad_slots; + delete win.__ts_request_id; + delete (win as TsAdInitWindow & { __tsAdInit?: () => boolean }).__tsAdInit; + delete (win as TsAdInitWindow & { __tsAdInitInstalled?: boolean }).__tsAdInitInstalled; + delete win.googletag; + originalFetch = globalThis.fetch; + originalSendBeacon = navigator.sendBeacon; + + const mod = await import('../../../src/integrations/gpt/index'); + installTsAdInit = mod.installTsAdInit; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + navigator.sendBeacon = originalSendBeacon; + delete win.__ts_ad_slots; + delete win.__ts_request_id; + delete (win as TsAdInitWindow & { __tsAdInit?: () => boolean }).__tsAdInit; + delete (win as TsAdInitWindow & { __tsAdInitInstalled?: boolean }).__tsAdInitInstalled; + delete win.googletag; + }); + + it('fetches request scoped bids without credentials', async () => { + win.__ts_ad_slots = []; + win.__ts_request_id = 'request id/1'; + globalThis.fetch = vi.fn(() => Promise.resolve(jsonResponse({}))) as unknown as typeof fetch; + createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(globalThis.fetch).toHaveBeenCalledWith('/ts-bids?rid=request%20id%2F1', { + credentials: 'omit', + }); + }); + + it('applies static slot targeting before refresh', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => Promise.resolve(jsonResponse({}))) as unknown as typeof fetch; + const { operations } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(operations.indexOf('set:div-atf-sidebar:pos:atf')).toBeGreaterThanOrEqual(0); + expect(operations.indexOf('set:div-atf-sidebar:pos:atf')).toBeLessThan( + operations.indexOf('refresh') + ); + }); + + it('applies hb targeting before refresh', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => + Promise.resolve( + jsonResponse({ + atf_sidebar: { + hb_pb: '1.20', + hb_bidder: 'rubicon', + hb_adid: 'ad-123', + }, + }) + ) + ) as unknown as typeof fetch; + const { operations } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + for (const field of ['hb_pb', 'hb_bidder', 'hb_adid']) { + const operation = operations.find((entry) => + entry.startsWith(`set:div-atf-sidebar:${field}:`) + ); + expect(operation).toBeDefined(); + expect(operations.indexOf(operation!)).toBeLessThan(operations.indexOf('refresh')); + } + }); + + it('refreshes GPT slots when bid fetch fails', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => + Promise.reject(new Error('network down')) + ) as unknown as typeof fetch; + const { pubadsService } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(pubadsService.refresh).toHaveBeenCalledTimes(1); + }); + + it('fires burl only after rendered slot targeting matches bid hb_adid', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => + Promise.resolve( + jsonResponse({ + atf_sidebar: { + hb_pb: '1.20', + hb_bidder: 'rubicon', + hb_adid: 'ad-123', + burl: 'https://bidder.example/bill', + }, + }) + ) + ) as unknown as typeof fetch; + navigator.sendBeacon = vi.fn(() => true); + const { slots, triggerSlotRenderEnded } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + slots[0].targeting.set('hb_adid', ['other-ad']); + triggerSlotRenderEnded(slots[0]); + expect(navigator.sendBeacon).not.toHaveBeenCalled(); + + slots[0].targeting.set('hb_adid', ['ad-123']); + triggerSlotRenderEnded(slots[0]); + expect(navigator.sendBeacon).toHaveBeenCalledWith('https://bidder.example/bill'); + }); +}); + describe('GPT shim – runtime gating', () => { type GatedWindow = Window & { __tsjs_gpt_enabled?: boolean; diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 40bcf7f2c..6be3040b5 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -61,6 +61,11 @@ const SECUREPUBADS_HOST: &str = "securepubads.g.doubleclick.net"; /// Integration route prefix for all GPT proxy endpoints. const ROUTE_PREFIX: &str = "/integrations/gpt"; +const GPT_SHIM_INSTALL_SNIPPET: &str = + "<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>"; + +const TS_AD_INIT_SNIPPET: &str = r#"<script>(function(w){if(w.__tsAdInitInstalled){return;}w.__tsAdInit=function(){if(w.__tsAdInitInstalled){return false;}w.__tsAdInitInstalled=true;var googletag=w.googletag=w.googletag||{};googletag.cmd=googletag.cmd||[];googletag.cmd.push(function(){var slots=w.__ts_ad_slots||[];var rid=w.__ts_request_id;var bidsByAdId={};var bidsPromise=(rid&&w.fetch?w.fetch('/ts-bids?rid='+encodeURIComponent(rid),{credentials:'omit'}).then(function(r){return r.json();}).catch(function(){return {};}):Promise.resolve({}));var pubads=googletag.pubads&&googletag.pubads();if(!pubads||!googletag.defineSlot){return;}if(pubads.addEventListener){pubads.addEventListener('slotRenderEnded',function(e){var adid=e&&e.slot&&e.slot.getTargeting&&e.slot.getTargeting('hb_adid')[0];var bid=adid&&bidsByAdId[adid];if(bid&&bid.burl&&w.navigator&&w.navigator.sendBeacon){w.navigator.sendBeacon(bid.burl);delete bidsByAdId[adid];}});}var gptSlots=[];slots.forEach(function(s){var slot=googletag.defineSlot(s.gam_unit_path,s.formats||[],s.div_id);if(!slot){return;}if(slot.addService){slot.addService(pubads);}Object.keys(s.targeting||{}).forEach(function(k){slot.setTargeting(k,s.targeting[k]);});gptSlots.push({def:s,slot:slot});});if(googletag.enableServices){googletag.enableServices();}gptSlots.forEach(function(pair){if(googletag.display){googletag.display(pair.def.div_id);}});bidsPromise.then(function(bids){gptSlots.forEach(function(pair){var bid=bids&&bids[pair.def.id];if(!bid){return;}['hb_pb','hb_bidder','hb_adid'].forEach(function(k){if(bid[k]!=null){pair.slot.setTargeting(k,String(bid[k]));}});if(bid.hb_adid){bidsByAdId[String(bid.hb_adid)]=bid;}});if(pubads.refresh){pubads.refresh(gptSlots.map(function(pair){return pair.slot;}));}});});return true;};w.__tsAdInit();})(window);</script>"#; + /// Configuration for the Google Publisher Tags integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct GptConfig { @@ -443,8 +448,8 @@ impl IntegrationHeadInjector for GptIntegration { // when it sees the pre-set flag, so this works regardless of whether // the inline bootstrap runs before or after the TSJS bundle. vec![ - "<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>" - .to_string(), + GPT_SHIM_INSTALL_SNIPPET.to_string(), + TS_AD_INIT_SNIPPET.to_string(), ] } } @@ -1020,11 +1025,54 @@ mod tests { let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should emit exactly one head insert"); - assert_eq!( - inserts[0], - "<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>", - "should set the enable flag and call the GPT shim activation function" + assert!( + inserts + .iter() + .any(|insert| insert == GPT_SHIM_INSTALL_SNIPPET), + "should keep the GPT shim activation snippet" + ); + } + + #[test] + fn head_injector_emits_ts_ad_init_bootstrap_without_inline_bids() { + 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 combined = integration.head_inserts(&ctx).join(""); + + assert!( + combined.contains("__tsAdInit"), + "should emit the server-side GPT bootstrap" + ); + assert!( + combined.contains("/ts-bids"), + "should fetch bids from the bid endpoint" + ); + assert!( + combined.contains("__ts_request_id"), + "should read the request ID global" + ); + assert!( + combined.contains("bidsPromise"), + "should create a bid fetch promise" + ); + assert!( + combined.contains("slotRenderEnded"), + "should listen for rendered GPT slots" + ); + assert!( + combined.contains("sendBeacon"), + "should fire billing notifications via sendBeacon" + ); + assert!( + !combined.contains("__ts_bids"), + "should not read or write inline bid globals" ); } From 90f050bb4637f20ebe5f4f7f11bc23dc7c5df7da Mon Sep 17 00:00:00 2001 From: prk-Jr <prakashsagwara@gmail.com> Date: Sun, 3 May 2026 21:58:25 +0530 Subject: [PATCH 17/24] Add ts-bids endpoint --- .../src/bid_cache.rs | 2 + .../trusted-server-adapter-fastly/src/main.rs | 83 ++++++- .../src/route_tests.rs | 232 +++++++++++++++++- 3 files changed, 311 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/bid_cache.rs b/crates/trusted-server-adapter-fastly/src/bid_cache.rs index 22fe7145b..92e23625a 100644 --- a/crates/trusted-server-adapter-fastly/src/bid_cache.rs +++ b/crates/trusted-server-adapter-fastly/src/bid_cache.rs @@ -26,6 +26,7 @@ impl FastlyBidCache { } /// Creates a Fastly bid cache with an explicit TTL. + #[cfg(test)] pub fn with_ttl(ttl: Duration) -> Self { Self { ttl, @@ -34,6 +35,7 @@ impl FastlyBidCache { } /// Creates a Fastly bid cache with explicit cache and deadline bounds. + #[cfg(test)] pub fn with_limits(ttl: Duration, max_reconstructed_wait: Duration) -> Self { Self { ttl, diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 64802fc78..eac746ba5 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,10 +1,12 @@ use error_stack::Report; -use fastly::http::Method; +use fastly::http::{header, Method, StatusCode}; use fastly::{Request, Response}; +use std::time::{Duration, Instant}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::bid_cache::{BidCache, BidMap, CacheResult, WaitResult}; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, @@ -28,8 +30,6 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; -// Used by the `/ts-bids` route in the next implementation task. -#[allow(dead_code)] mod bid_cache; mod error; mod logging; @@ -38,10 +38,14 @@ mod platform; #[cfg(test)] mod route_tests; +use crate::bid_cache::FastlyBidCache; use crate::error::to_error_response; use crate::logging::init_logger; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; +const TS_BIDS_NO_STORE: &str = "private, no-store"; +const TS_BIDS_POLL_INTERVAL: Duration = Duration::from_millis(5); + /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `Request::from_client()` instead of @@ -98,6 +102,7 @@ fn main() { let kv_store = std::sync::Arc::new(UnavailableKvStore) as std::sync::Arc<dyn trusted_server_core::platform::PlatformKvStore>; let runtime_services = build_runtime_services(&req, kv_store); + let bid_cache = FastlyBidCache::new(); // route_request may send the response directly (streaming path) or // return it for us to send (buffered path). @@ -106,6 +111,7 @@ fn main() { &orchestrator, &integration_registry, &runtime_services, + &bid_cache, req, )) { response.send_to_client(); @@ -117,6 +123,7 @@ async fn route_request( orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, runtime_services: &RuntimeServices, + bid_cache: &impl BidCache, mut req: Request, ) -> Option<Response> { // Strip client-spoofable forwarded headers at the edge. @@ -157,6 +164,8 @@ async fn route_request( // Match known routes and handle them let result = match (method, path.as_str()) { + (Method::GET, "/ts-bids") => Ok(handle_ts_bids_request(&req, bid_cache)), + // Serve the tsjs library (Method::GET, path) if path.starts_with("/static/tsjs=") => { handle_tsjs_dynamic(&req, integration_registry) @@ -275,10 +284,78 @@ async fn route_request( let mut response = result.unwrap_or_else(|e| to_error_response(&e)); finalize_response(settings, geo_info.as_ref(), &mut response); + if path == "/ts-bids" { + response.set_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE); + } Some(response) } +fn handle_ts_bids_request(req: &Request, bid_cache: &impl BidCache) -> Response { + let Some(request_id) = req + .get_url() + .query_pairs() + .find_map(|(key, value)| (key == "rid").then_some(value.into_owned())) + .filter(|value| !value.is_empty()) + else { + return text_ts_bids_response(StatusCode::BAD_REQUEST, "missing rid\n"); + }; + + match bid_cache.try_get(&request_id) { + Ok(CacheResult::Complete { bids }) => json_ts_bids_response(StatusCode::OK, &bids), + Ok(CacheResult::Pending { auction_deadline }) => { + match wait_for_ts_bids(bid_cache, &request_id, auction_deadline.instant) { + WaitResult::Bids(bids) => json_ts_bids_response(StatusCode::OK, &bids), + WaitResult::Empty => empty_ts_bids_response(), + WaitResult::NotFound => text_ts_bids_response(StatusCode::NOT_FOUND, "not found\n"), + } + } + Ok(CacheResult::NotFound) => text_ts_bids_response(StatusCode::NOT_FOUND, "not found\n"), + Err(error) => { + log::warn!("Failed to read bid cache: {error:?}"); + empty_ts_bids_response() + } + } +} + +fn wait_for_ts_bids(bid_cache: &impl BidCache, request_id: &str, deadline: Instant) -> WaitResult { + loop { + match bid_cache.try_get(request_id) { + Ok(CacheResult::Complete { bids }) => return WaitResult::Bids(bids), + Ok(CacheResult::NotFound) => return WaitResult::NotFound, + Ok(CacheResult::Pending { .. }) => { + if Instant::now() >= deadline { + return WaitResult::Empty; + } + std::thread::sleep(TS_BIDS_POLL_INTERVAL); + } + Err(error) => { + log::warn!("Failed to poll bid cache: {error:?}"); + return WaitResult::Empty; + } + } + } +} + +fn empty_ts_bids_response() -> Response { + json_ts_bids_response(StatusCode::OK, &BidMap::new()) +} + +fn json_ts_bids_response(status: StatusCode, bids: &BidMap) -> Response { + let body = serde_json::to_string(&bids).expect("should serialize bid map"); + + Response::from_status(status) + .with_header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .with_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE) + .with_body(body) +} + +fn text_ts_bids_response(status: StatusCode, body: &str) -> Response { + Response::from_status(status) + .with_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE) + .with_body_text_plain(body) +} + fn runtime_services_for_consent_route( settings: &Settings, runtime_services: &RuntimeServices, diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 0fd0113f8..931b53baf 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -3,9 +3,12 @@ use std::sync::Arc; use edgezero_core::key_value_store::NoopKvStore; use error_stack::Report; -use fastly::http::StatusCode; -use fastly::Request; +use fastly::http::{header, StatusCode}; +use fastly::{Request, Response}; +use serde_json::json; +use std::time::{Duration, Instant}; use trusted_server_core::auction::build_orchestrator; +use trusted_server_core::bid_cache::{AuctionDeadline, BidMap, InMemoryBidCache}; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -16,7 +19,7 @@ use trusted_server_core::platform::{ use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME; use trusted_server_core::settings::Settings; -use super::route_request; +use super::{handle_ts_bids_request, route_request}; struct StubJwksConfigStore; @@ -178,12 +181,168 @@ fn test_runtime_services(req: &Request) -> RuntimeServices { .build() } +fn response_body(mut response: Response) -> String { + response.take_body_str() +} + +fn test_bid_cache() -> InMemoryBidCache { + InMemoryBidCache::new(Duration::from_secs(1), 8) +} + +fn immediate_deadline() -> AuctionDeadline { + AuctionDeadline::from_parts(Instant::now(), 1_700_000_000_000) +} + +fn slot_bid_map() -> BidMap { + BidMap::from([( + "atf_sidebar".to_string(), + json!({ + "hb_pb": "1.20", + "hb_bidder": "rubicon", + "hb_adid": "ad-123", + "burl": "https://bidder.example/bill" + }), + )]) +} + +#[test] +fn ts_bids_missing_rid_returns_bad_request_no_store() { + let cache = test_bid_cache(); + let response = handle_ts_bids_request(&Request::get("https://test.com/ts-bids"), &cache); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should reject missing request ID" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_empty_rid_returns_bad_request_no_store() { + let cache = test_bid_cache(); + let response = handle_ts_bids_request(&Request::get("https://test.com/ts-bids?rid="), &cache); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should reject empty request ID" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_unknown_rid_returns_not_found_no_store() { + let cache = test_bid_cache(); + let response = handle_ts_bids_request( + &Request::get("https://test.com/ts-bids?rid=missing"), + &cache, + ); + + assert_eq!( + response.get_status(), + StatusCode::NOT_FOUND, + "should return 404 for unknown request IDs" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_completed_rid_returns_bid_json_no_store() { + let cache = test_bid_cache(); + let bids = slot_bid_map(); + cache.put("rid-1", bids.clone()).expect("should store bids"); + + let response = + handle_ts_bids_request(&Request::get("https://test.com/ts-bids?rid=rid-1"), &cache); + assert_eq!( + response.get_status(), + StatusCode::OK, + "should return completed bids" + ); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/json; charset=utf-8"), + "should return JSON" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); + let body: serde_json::Value = + serde_json::from_str(&response_body(response)).expect("should parse JSON body"); + assert_eq!(body, json!(bids), "should serialize bid map"); +} + +#[test] +fn ts_bids_completed_empty_map_returns_empty_json_no_store() { + let cache = test_bid_cache(); + cache + .put_empty("rid-empty") + .expect("should store empty bid map"); + + let response = handle_ts_bids_request( + &Request::get("https://test.com/ts-bids?rid=rid-empty"), + &cache, + ); + + assert_eq!(response.get_status(), StatusCode::OK, "should return OK"); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); + assert_eq!( + response_body(response), + "{}", + "should return empty JSON object" + ); +} + +#[test] +fn ts_bids_pending_until_original_deadline_returns_empty_json() { + let cache = test_bid_cache(); + cache + .mark_pending("rid-pending", immediate_deadline()) + .expect("should mark pending"); + + let response = handle_ts_bids_request( + &Request::get("https://test.com/ts-bids?rid=rid-pending"), + &cache, + ); + + assert_eq!( + response.get_status(), + StatusCode::OK, + "should return OK after pending deadline" + ); + assert_eq!( + response_body(response), + "{}", + "should return empty JSON object" + ); +} + #[test] fn configured_missing_consent_store_only_breaks_consent_routes() { let settings = create_test_settings(); let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); + let bid_cache = test_bid_cache(); let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); let discovery_services = test_runtime_services(&discovery_req); @@ -192,6 +351,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &discovery_services, + &bid_cache, discovery_req, )) .expect("should route discovery request"); @@ -208,6 +368,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &admin_services, + &bid_cache, admin_req, )) .expect("should route admin request"); @@ -224,6 +385,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &auction_services, + &bid_cache, auction_req, )) .expect("should return an error response for auction requests"); @@ -240,6 +402,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &publisher_services, + &bid_cache, publisher_req, )) .expect("should return an error response for publisher fallback"); @@ -249,3 +412,66 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { "should scope consent store failures to the consent-dependent routes" ); } + +#[test] +fn ts_bids_route_is_handled_before_publisher_fallback() { + let settings = create_test_settings(); + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let req = Request::get("https://test.com/ts-bids"); + let runtime_services = test_runtime_services(&req); + let bid_cache = test_bid_cache(); + + let response = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &runtime_services, + &bid_cache, + req, + )) + .expect("should route ts-bids request"); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should handle ts-bids before consent-dependent publisher fallback" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_route_keeps_no_store_after_response_header_finalization() { + let mut settings = create_test_settings(); + settings.response_headers.insert( + header::CACHE_CONTROL.as_str().to_string(), + "public, max-age=300".to_string(), + ); + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let req = Request::get("https://test.com/ts-bids"); + let runtime_services = test_runtime_services(&req); + let bid_cache = test_bid_cache(); + + let response = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &runtime_services, + &bid_cache, + req, + )) + .expect("should route ts-bids request"); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "ts-bids no-store must win over configured response headers" + ); +} From 38541412901497a4ae72fcc8186a6ed8f2026b21 Mon Sep 17 00:00:00 2001 From: prk-Jr <prakashsagwara@gmail.com> Date: Mon, 4 May 2026 07:41:42 +0530 Subject: [PATCH 18/24] Start ad template auctions without blocking HTML streaming --- Cargo.lock | 1 + .../trusted-server-adapter-fastly/Cargo.toml | 1 + .../trusted-server-adapter-fastly/src/main.rs | 59 +- .../src/platform.rs | 63 +- .../src/route_tests.rs | 11 + .../src/auction/orchestrator.rs | 444 ++++++++++++-- .../trusted-server-core/src/platform/http.rs | 22 + .../trusted-server-core/src/platform/mod.rs | 4 +- .../src/platform/test_support.rs | 57 +- crates/trusted-server-core/src/publisher.rs | 577 +++++++++++++++++- .../2026-04-30-server-side-ad-templates.md | 42 +- 11 files changed, 1198 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index caf9433d0..65d1d777c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2713,6 +2713,7 @@ dependencies = [ "log-fastly", "serde", "serde_json", + "toml 1.0.7+spec-1.1.0", "trusted-server-core", "urlencoding", ] diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index e483ea621..a730efcd6 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -20,6 +20,7 @@ log = { workspace = true } log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } trusted-server-core = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index eac746ba5..963634cf8 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,6 +1,7 @@ use error_stack::Report; use fastly::http::{header, Method, StatusCode}; use fastly::{Request, Response}; +use std::sync::LazyLock; use std::time::{Duration, Instant}; use trusted_server_core::auction::endpoints::handle_auction; @@ -11,6 +12,7 @@ use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; +use trusted_server_core::creative_opportunities::CreativeOpportunitiesFile; use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -21,7 +23,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_publisher_request, handle_tsjs_dynamic, stream_publisher_body, + stream_publisher_body_with_ad_auction, PublisherResponse, ServerSideAuctionStreamContext, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -45,6 +48,11 @@ use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore} const TS_BIDS_NO_STORE: &str = "private, no-store"; const TS_BIDS_POLL_INTERVAL: Duration = Duration::from_millis(5); +const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); + +static CREATIVE_OPPORTUNITIES: LazyLock<CreativeOpportunitiesFile> = LazyLock::new(|| { + toml::from_str(CREATIVE_OPPORTUNITIES_TOML).expect("should parse creative-opportunities.toml") +}); /// Entry point for the Fastly Compute program. /// @@ -111,6 +119,7 @@ fn main() { &orchestrator, &integration_registry, &runtime_services, + &CREATIVE_OPPORTUNITIES, &bid_cache, req, )) { @@ -123,6 +132,7 @@ async fn route_request( orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, runtime_services: &RuntimeServices, + creative_opportunities: &CreativeOpportunitiesFile, bid_cache: &impl BidCache, mut req: Request, ) -> Option<Response> { @@ -233,23 +243,51 @@ async fn route_request( settings, integration_registry, &publisher_services, + orchestrator, + creative_opportunities, + bid_cache, req, ) { Ok(PublisherResponse::Stream { mut response, body, params, + mut server_side_auction, }) => { // Streaming path: finalize headers, then stream body to client. + let preserve_no_store = response + .get_header_str(header::CACHE_CONTROL) + .is_some_and(|value| value == TS_BIDS_NO_STORE); finalize_response(settings, geo_info.as_ref(), &mut response); + if preserve_no_store { + response.set_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE); + } let mut streaming_body = response.stream_to_client(); - if let Err(e) = stream_publisher_body( - body, - &mut streaming_body, - ¶ms, - settings, - integration_registry, - ) { + let stream_result = + if let Some(auction) = server_side_auction.as_deref_mut() { + let mut auction_context = ServerSideAuctionStreamContext { + services: &publisher_services, + bid_cache, + server_side_auction: auction, + }; + stream_publisher_body_with_ad_auction( + body, + &mut streaming_body, + ¶ms, + settings, + integration_registry, + &mut auction_context, + ) + } else { + stream_publisher_body( + body, + &mut streaming_body, + ¶ms, + settings, + integration_registry, + ) + }; + 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:?}"); @@ -282,9 +320,12 @@ async fn route_request( // Convert any errors to HTTP error responses let mut response = result.unwrap_or_else(|e| to_error_response(&e)); + let preserve_no_store = response + .get_header_str(header::CACHE_CONTROL) + .is_some_and(|value| value == TS_BIDS_NO_STORE); finalize_response(settings, geo_info.as_ref(), &mut response); - if path == "/ts-bids" { + if path == "/ts-bids" || preserve_no_store { response.set_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE); } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index dd1f098b8..946b07ebd 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -12,6 +12,7 @@ use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; use fastly::geo::geo_lookup; +use fastly::http::request::{PendingRequest, PollResult}; use fastly::{ConfigStore, Request, SecretStore}; use trusted_server_core::backend::BackendConfig; @@ -20,8 +21,8 @@ pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, - StoreName, + PlatformPollResult, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -315,6 +316,46 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { Ok(PlatformSelectResult { ready, remaining }) } + + async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result<PlatformPollResult, Report<PlatformError>> { + let backend_name = pending.backend_name().map(str::to_string); + let inner = pending.downcast::<PendingRequest>().map_err(|platform_req| { + let backend_name = platform_req.backend_name().unwrap_or("<unknown>"); + Report::new(PlatformError::HttpClient).attach(format!( + "PlatformPendingRequest inner type is not fastly::PendingRequest for backend '{backend_name}'" + )) + })?; + + match inner.poll() { + PollResult::Pending(pending) => { + let mut platform_pending = PlatformPendingRequest::new(pending); + if let Some(backend_name) = backend_name { + platform_pending = platform_pending.with_backend_name(backend_name); + } + Ok(PlatformPollResult::Pending(platform_pending)) + } + PollResult::Done(Ok(fastly_resp)) => { + let backend_name = fastly_resp + .get_backend_name() + .unwrap_or_else(|| { + log::warn!("poll: response has no backend name, correlation will fail"); + "" + }) + .to_string(); + Ok(PlatformPollResult::Ready(fastly_response_to_platform( + fastly_resp, + backend_name, + ))) + } + PollResult::Done(Err(error)) => Ok(PlatformPollResult::Ready(Err(Report::new( + PlatformError::HttpClient, + ) + .attach(format!("fastly poll error: {error}"))))), + } + } } // --------------------------------------------------------------------------- @@ -583,6 +624,24 @@ mod tests { ); } + #[test] + fn fastly_platform_http_client_poll_returns_error_for_wrong_inner_type() { + let client = FastlyPlatformHttpClient; + let wrong = PlatformPendingRequest::new(42u32).with_backend_name("origin-a"); + let err = futures::executor::block_on(client.poll(wrong)) + .expect_err("should return error for wrong inner type"); + + assert!( + matches!(err.current_context(), &PlatformError::HttpClient), + "should be HttpClient error, got: {:?}", + err.current_context() + ); + assert!( + format!("{err:?}").contains("origin-a"), + "should include backend name in error report: {err:?}" + ); + } + #[test] fn fastly_platform_http_client_send_returns_error_for_streaming_body() { let client = FastlyPlatformHttpClient; diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 931b53baf..42ef98d4e 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -9,6 +9,7 @@ use serde_json::json; use std::time::{Duration, Instant}; use trusted_server_core::auction::build_orchestrator; use trusted_server_core::bid_cache::{AuctionDeadline, BidMap, InMemoryBidCache}; +use trusted_server_core::creative_opportunities::CreativeOpportunitiesFile; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -189,6 +190,10 @@ fn test_bid_cache() -> InMemoryBidCache { InMemoryBidCache::new(Duration::from_secs(1), 8) } +fn empty_creative_opportunities() -> CreativeOpportunitiesFile { + CreativeOpportunitiesFile::default() +} + fn immediate_deadline() -> AuctionDeadline { AuctionDeadline::from_parts(Instant::now(), 1_700_000_000_000) } @@ -351,6 +356,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &discovery_services, + &empty_creative_opportunities(), &bid_cache, discovery_req, )) @@ -368,6 +374,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &admin_services, + &empty_creative_opportunities(), &bid_cache, admin_req, )) @@ -385,6 +392,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &auction_services, + &empty_creative_opportunities(), &bid_cache, auction_req, )) @@ -402,6 +410,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &publisher_services, + &empty_creative_opportunities(), &bid_cache, publisher_req, )) @@ -428,6 +437,7 @@ fn ts_bids_route_is_handled_before_publisher_fallback() { &orchestrator, &integration_registry, &runtime_services, + &empty_creative_opportunities(), &bid_cache, req, )) @@ -464,6 +474,7 @@ fn ts_bids_route_keeps_no_store_after_response_header_finalization() { &orchestrator, &integration_registry, &runtime_services, + &empty_creative_opportunities(), &bid_cache, req, )) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index a096bd080..cfe239f2c 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::error::TrustedServerError; -use crate::platform::{PlatformPendingRequest, RuntimeServices}; +use crate::platform::{PlatformPendingRequest, PlatformPollResult, RuntimeServices}; use crate::proxy::platform_response_to_fastly; use super::config::AuctionConfig; @@ -23,12 +23,214 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } +fn select_winning_bids_from_responses( + responses: &[AuctionResponse], + floor_prices: &HashMap<String, f64>, +) -> HashMap<String, Bid> { + let mut winning_bids: HashMap<String, Bid> = HashMap::new(); + + for response in responses { + if response.status != BidStatus::Success { + continue; + } + + for bid in &response.bids { + let bid_price = match bid.price { + Some(price) => price, + None => { + log::debug!( + "Skipping bid for slot '{}' from '{}' - price requires mediation to decode", + bid.slot_id, + bid.bidder + ); + continue; + } + }; + + let should_replace = match winning_bids.get(&bid.slot_id) { + Some(current_winner) => current_winner + .price + .is_none_or(|current_price| bid_price > current_price), + None => true, + }; + + if should_replace { + winning_bids.insert(bid.slot_id.clone(), bid.clone()); + } + } + } + + if floor_prices.is_empty() { + log::info!("Selected {} winning bids", winning_bids.len()); + return winning_bids; + } + + let starting_count = winning_bids.len(); + winning_bids.retain(|slot_id, bid| match floor_prices.get(slot_id) { + Some(floor) => match bid.price { + Some(price) if price >= *floor => true, + Some(_) => { + log::info!("Dropping winning bid below floor price for slot '{slot_id}'"); + false + } + None => true, + }, + None => true, + }); + + if winning_bids.len() != starting_count { + log::info!( + "Filtered winning bids by floor price: {} -> {}", + starting_count, + winning_bids.len() + ); + } + + log::info!("Selected {} winning bids", winning_bids.len()); + winning_bids +} + /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { config: AuctionConfig, providers: HashMap<String, Arc<dyn AuctionProvider>>, } +/// Server-side template auction that can advance without blocking page streaming. +pub struct PendingAuction { + request: AuctionRequest, + pending: Vec<PendingProviderRequest>, + provider_responses: Vec<AuctionResponse>, + floor_prices: HashMap<String, f64>, + auction_started_at: Instant, + auction_deadline: Instant, +} + +struct PendingProviderRequest { + provider: Arc<dyn AuctionProvider>, + started_at: Instant, + pending: PlatformPendingRequest, +} + +/// Result of a non-blocking pending auction poll. +pub enum PendingAuctionPoll { + /// At least one provider is still in flight. + Pending, + /// The auction completed or reached its original deadline. + Complete(OrchestrationResult), +} + +impl PendingAuction { + /// Builds a completed pending auction for stream-polling tests. + #[cfg(test)] + pub(crate) fn from_completed_result_for_test( + request: AuctionRequest, + result: OrchestrationResult, + ) -> Self { + Self { + request, + pending: Vec::new(), + provider_responses: result.provider_responses, + floor_prices: HashMap::new(), + auction_started_at: Instant::now(), + auction_deadline: Instant::now(), + } + } + + /// Advance provider requests once without blocking. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the platform poll operation fails. + pub async fn poll_once( + &mut self, + services: &RuntimeServices, + ) -> Result<PendingAuctionPoll, Report<TrustedServerError>> { + let mut still_pending = Vec::with_capacity(self.pending.len()); + + for pending_provider in self.pending.drain(..) { + match services + .http_client() + .poll(pending_provider.pending) + .await + .change_context(TrustedServerError::Auction { + message: format!( + "HTTP poll failed for provider '{}'", + pending_provider.provider.provider_name() + ), + })? { + PlatformPollResult::Pending(pending) => { + still_pending.push(PendingProviderRequest { + pending, + ..pending_provider + }); + } + PlatformPollResult::Ready(Ok(platform_response)) => { + let response_time_ms = pending_provider.started_at.elapsed().as_millis() as u64; + match platform_response_to_fastly(platform_response).and_then(|response| { + pending_provider.provider.parse_response_for_request( + response, + response_time_ms, + &self.request, + ) + }) { + Ok(response) => self.provider_responses.push(response), + Err(error) => { + log::warn!( + "Provider '{}' failed during non-blocking auction poll: {error:?}", + pending_provider.provider.provider_name() + ); + self.provider_responses.push(AuctionResponse::error( + pending_provider.provider.provider_name(), + response_time_ms, + )); + } + } + } + PlatformPollResult::Ready(Err(error)) => { + log::warn!( + "Provider '{}' poll completed with error: {error:?}", + pending_provider.provider.provider_name() + ); + self.provider_responses.push(AuctionResponse::error( + pending_provider.provider.provider_name(), + pending_provider.started_at.elapsed().as_millis() as u64, + )); + } + } + } + + self.pending = still_pending; + + if self.pending.is_empty() || Instant::now() >= self.auction_deadline { + Ok(PendingAuctionPoll::Complete(self.finish_due_to_deadline())) + } else { + Ok(PendingAuctionPoll::Pending) + } + } + + /// Finish the auction using responses collected so far. + #[must_use] + pub fn finish_due_to_deadline(&self) -> OrchestrationResult { + OrchestrationResult { + provider_responses: self.provider_responses.clone(), + mediator_response: None, + winning_bids: select_winning_bids_from_responses( + &self.provider_responses, + &self.floor_prices, + ), + total_time_ms: self.auction_started_at.elapsed().as_millis() as u64, + metadata: HashMap::new(), + } + } + + /// Return whether no provider requests remain in flight. + #[must_use] + pub fn is_complete(&self) -> bool { + self.pending.is_empty() + } +} + impl AuctionOrchestrator { /// Create a new orchestrator with the given configuration. #[must_use] @@ -95,6 +297,92 @@ impl AuctionOrchestrator { }) } + /// Start a server-side template auction without waiting for provider responses. + /// + /// The returned [`PendingAuction`] must be advanced with + /// [`PendingAuction::poll_once`] while the page response streams. + /// + /// # Errors + /// + /// Returns an error only when launching a provider request fails in a way + /// that should abort the auction setup. Individual provider launch failures + /// are logged and skipped. + pub fn start_server_side_auction( + &self, + request: AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result<PendingAuction, Report<TrustedServerError>> { + let auction_started_at = Instant::now(); + let auction_deadline = auction_started_at + .checked_add(Duration::from_millis(u64::from(context.timeout_ms))) + .unwrap_or(auction_started_at); + let mut pending = Vec::new(); + + for provider_name in self.config.provider_names() { + let Some(provider) = self.providers.get(provider_name).cloned() else { + 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_started_at, 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 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, + }; + + match provider.request_bids(&request, &provider_context) { + Ok(provider_pending) => { + let mut platform_pending = PlatformPendingRequest::new(provider_pending); + if let Some(backend_name) = provider.backend_name(effective_timeout) { + platform_pending = platform_pending.with_backend_name(backend_name); + } + pending.push(PendingProviderRequest { + provider, + started_at: Instant::now(), + pending: platform_pending, + }); + } + Err(error) => { + log::warn!( + "Provider '{}' failed to launch request: {error:?}", + provider.provider_name() + ); + } + } + } + + Ok(PendingAuction { + floor_prices: self.floor_prices_by_slot(&request), + request, + pending, + provider_responses: Vec::new(), + auction_started_at, + auction_deadline, + }) + } + /// Run auction with parallel bidding + mediation. /// /// Flow: @@ -478,42 +766,7 @@ impl AuctionOrchestrator { responses: &[AuctionResponse], floor_prices: &HashMap<String, f64>, ) -> HashMap<String, Bid> { - let mut winning_bids: HashMap<String, Bid> = HashMap::new(); - - for response in responses { - if response.status != BidStatus::Success { - continue; - } - - for bid in &response.bids { - // Skip bids without decoded prices (e.g., APS bids) - // These require mediation layer to decode - let bid_price = match bid.price { - Some(p) => p, - None => { - log::debug!( - "Skipping bid for slot '{}' from '{}' - price requires mediation to decode", - bid.slot_id, - bid.bidder - ); - continue; - } - }; - - let should_replace = match winning_bids.get(&bid.slot_id) { - Some(current_winner) => current_winner - .price - .is_none_or(|current_price| bid_price > current_price), - None => true, - }; - - if should_replace { - winning_bids.insert(bid.slot_id.clone(), bid.clone()); - } - } - } - - self.apply_floor_prices(winning_bids, floor_prices) + select_winning_bids_from_responses(responses, floor_prices) } fn apply_floor_prices( @@ -637,10 +890,12 @@ impl OrchestrationResult { #[cfg(test)] mod tests { use crate::auction::config::AuctionConfig; + use crate::auction::provider::AuctionProvider; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionRequest, Bid, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionRequest, AuctionResponse, Bid, MediaType, PublisherInfo, UserInfo, }; + use crate::error::TrustedServerError; // All-None ClientInfo used across tests that don't need real IP/TLS data. // Defined as a const so &EMPTY_CLIENT_INFO has 'static lifetime, avoiding @@ -650,12 +905,19 @@ mod tests { tls_protocol: None, tls_cipher: None, }; - use crate::platform::test_support::noop_services; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; + use crate::platform::{PlatformHttpClient, PlatformHttpRequest}; use crate::test_support::tests::crate_test_settings_str; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use error_stack::Report; use fastly::Request; use std::collections::{HashMap, HashSet}; + use std::sync::Arc; - use super::AuctionOrchestrator; + use super::{AuctionOrchestrator, PendingAuction, PendingAuctionPoll, PendingProviderRequest}; fn create_test_auction_request() -> AuctionRequest { AuctionRequest { @@ -704,6 +966,59 @@ mod tests { crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") } + struct TestAuctionProvider; + + impl AuctionProvider for TestAuctionProvider { + fn provider_name(&self) -> &'static str { + "test-provider" + } + + fn request_bids( + &self, + _request: &AuctionRequest, + _context: &crate::auction::types::AuctionContext<'_>, + ) -> Result<fastly::http::request::PendingRequest, Report<TrustedServerError>> { + Err(Report::new(TrustedServerError::Auction { + message: "test provider does not launch real Fastly requests".to_string(), + })) + } + + fn parse_response( + &self, + mut response: fastly::Response, + response_time_ms: u64, + ) -> Result<AuctionResponse, Report<TrustedServerError>> { + let body = response.take_body_str(); + let price = body.parse::<f64>().unwrap_or(1.5); + Ok(AuctionResponse::success( + self.provider_name(), + vec![Bid { + slot_id: "header-banner".to_string(), + price: Some(price), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "test-bidder".to_string(), + width: 728, + height: 90, + nurl: None, + burl: None, + ad_id: Some("ad-123".to_string()), + metadata: HashMap::new(), + }], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option<String> { + Some("backend-poll".to_string()) + } + } + #[test] fn filters_winning_bids_below_floor() { let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); @@ -762,6 +1077,55 @@ mod tests { ); } + #[test] + fn pending_auction_poll_once_completes_ready_provider_response() { + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(200, b"2.25".to_vec()); + let services = build_services_with_http_client(http_client.clone()); + let pending = futures::executor::block_on( + http_client.send_async(PlatformHttpRequest::new( + request_builder() + .method("POST") + .uri("https://bidder.example/openrtb2/auction") + .body(Body::empty()) + .expect("should build platform request"), + "backend-poll", + )), + ) + .expect("should create pending request"); + let provider = Arc::new(TestAuctionProvider); + let request = create_test_auction_request(); + let floor_prices = HashMap::from([("header-banner".to_string(), 1.50)]); + let mut pending_auction = PendingAuction { + request, + pending: vec![PendingProviderRequest { + provider, + started_at: std::time::Instant::now(), + pending, + }], + provider_responses: Vec::new(), + floor_prices, + auction_started_at: std::time::Instant::now(), + auction_deadline: std::time::Instant::now() + std::time::Duration::from_millis(50), + }; + + let result = + futures::executor::block_on(pending_auction.poll_once(&services)).expect("should poll"); + + let PendingAuctionPoll::Complete(result) = result else { + panic!("should complete after ready provider response"); + }; + let winner = result + .winning_bids + .get("header-banner") + .expect("should select winning bid"); + assert_eq!(winner.price, Some(2.25), "should parse provider response"); + assert!( + pending_auction.is_complete(), + "should have no pending providers after completion" + ); + } + // TODO: Re-enable provider integration tests after implementing mock support // for send_async(). Mock providers can't create PendingRequest without real // Fastly backends. diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index b6efe1b4e..0b1013234 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -148,6 +148,15 @@ pub struct PlatformSelectResult { pub remaining: Vec<PlatformPendingRequest>, } +/// Result of non-blocking polling for one in-flight request. +#[derive(Debug)] +pub enum PlatformPollResult { + /// The request is still pending and can be polled again later. + Pending(PlatformPendingRequest), + /// The request completed or failed. + Ready(Result<PlatformResponse, Report<PlatformError>>), +} + /// Outbound HTTP client abstraction. /// /// Supports both single-request sends ([`Self::send`]) and async fan-out @@ -194,6 +203,19 @@ pub trait PlatformHttpClient: Send + Sync { pending_requests: Vec<PlatformPendingRequest>, ) -> Result<PlatformSelectResult, Report<PlatformError>>; + /// Poll a single in-flight request without blocking. + /// + /// # Errors + /// + /// Returns `PlatformError::Unsupported` when the platform adapter does not + /// provide non-blocking polling. + async fn poll( + &self, + _pending: PlatformPendingRequest, + ) -> Result<PlatformPollResult, Report<PlatformError>> { + Err(Report::new(PlatformError::Unsupported)) + } + /// Wait for a single in-flight request to complete. /// /// This is a convenience wrapper around [`select`](Self::select) for the diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 6662e8374..61c3ae29e 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -42,8 +42,8 @@ mod types; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; pub use http::{ - PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, + PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformPollResult, + PlatformResponse, PlatformSelectResult, }; pub use kv::UnavailableKvStore; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 63628ad68..b06423f6d 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -9,8 +9,9 @@ use rand::rngs::OsRng; use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, + PlatformPollResult, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + RuntimeServices, StoreId, StoreName, }; use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; @@ -354,6 +355,26 @@ impl PlatformHttpClient for StubHttpClient { remaining: pending_requests, }) } + + async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result<PlatformPollResult, Report<PlatformError>> { + let stub = pending.downcast::<StubPendingResponse>().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("unexpected inner type in StubHttpClient::poll") + })?; + + let edge_response = edgezero_core::http::response_builder() + .status(stub.status) + .body(edgezero_core::body::Body::from(stub.body)) + .change_context(PlatformError::HttpClient)?; + + Ok(PlatformPollResult::Ready(Ok(PlatformResponse::new( + edge_response, + ) + .with_backend_name(stub.backend_name)))) + } } pub(crate) struct NoopGeo; @@ -596,6 +617,38 @@ mod tests { ); } + #[test] + fn stub_http_client_poll_returns_ready_response() { + let stub = StubHttpClient::new(); + stub.push_response(202, b"poll-ready".to_vec()); + let req = PlatformHttpRequest::new( + request_builder() + .method("GET") + .uri("https://example.com/bid") + .body(Body::empty()) + .expect("should build request"), + "backend-poll", + ); + let pending = + futures::executor::block_on(stub.send_async(req)).expect("should start request"); + + let result = futures::executor::block_on(stub.poll(pending)).expect("should poll request"); + + let PlatformPollResult::Ready(Ok(response)) = result else { + panic!("should return ready response"); + }; + assert_eq!( + response.backend_name.as_deref(), + Some("backend-poll"), + "should preserve backend correlation" + ); + assert_eq!( + response.response.status(), + 202, + "should preserve response status" + ); + } + #[test] fn stub_backend_returns_fixed_name() { let stub = StubBackend; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 01dcf7ee3..62961991e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -11,19 +11,28 @@ //! `fastly::Request`/`Response`/`Body` migrations. It is not a //! content-rewriting concern. -use std::io::Write; +use std::collections::HashMap; +use std::io::{self, Write}; use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; +use crate::auction::orchestrator::{AuctionOrchestrator, PendingAuction, PendingAuctionPoll}; +use crate::auction::types::{ + AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, +}; use crate::backend::BackendConfig; +use crate::bid_cache::{BidCache, BidMap}; use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_TS_EC, HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; use crate::cookies::{expire_ec_cookie, handle_request_cookies, set_ec_cookie}; -use crate::creative_opportunities::{CreativeOpportunitiesConfig, CreativeOpportunitySlot}; -use crate::edge_cookie::get_or_generate_ec_id; +use crate::creative_opportunities::{ + match_slots, CreativeOpportunitiesConfig, CreativeOpportunitiesFile, CreativeOpportunitySlot, +}; +use crate::edge_cookie::{generate_ec_id, get_or_generate_ec_id}; use crate::error::TrustedServerError; +use crate::geo::GeoInfo; use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::integrations::IntegrationRegistry; use crate::platform::RuntimeServices; @@ -281,6 +290,71 @@ fn apply_server_side_ad_cache_policy( } } +pub(crate) struct ServerSideAuctionRequestParams<'a> { + request_id: &'a str, + matched_slots: &'a [&'a CreativeOpportunitySlot], + settings: &'a Settings, + services: &'a RuntimeServices, + req: &'a Request, + consent_context: &'a crate::consent::ConsentContext, + ec_id: &'a str, + geo: Option<GeoInfo>, +} + +fn build_server_side_auction_request( + params: ServerSideAuctionRequestParams<'_>, +) -> Result<AuctionRequest, Report<TrustedServerError>> { + let page_url = params.req.get_url().to_string(); + let fresh_id = generate_ec_id(params.settings, params.services).change_context( + TrustedServerError::Auction { + message: "Failed to generate fresh EC ID for server-side ad auction".to_string(), + }, + )?; + + Ok(AuctionRequest { + id: params.request_id.to_string(), + slots: params + .matched_slots + .iter() + .map(|slot| slot.to_ad_slot()) + .collect(), + publisher: PublisherInfo { + domain: params.settings.publisher.domain.clone(), + page_url: Some(page_url.clone()), + }, + user: UserInfo { + id: params.ec_id.to_string(), + fresh_id, + consent: Some(params.consent_context.clone()), + }, + device: Some(DeviceInfo { + user_agent: params + .req + .get_header_str(header::USER_AGENT) + .map(str::to_string), + ip: params + .services + .client_info + .client_ip + .map(|ip| ip.to_string()), + geo: params.geo, + }), + site: Some(SiteInfo { + domain: params.settings.publisher.domain.clone(), + page: page_url, + }), + context: HashMap::new(), + }) +} + +fn clone_request_metadata_for_auction(req: &Request) -> Request { + let mut cloned = Request::new(req.get_method().clone(), req.get_url().clone()); + for (name, value) in req.get_headers() { + cloned.append_header(name, value.clone()); + } + cloned +} + /// Parameters for processing response streaming struct ProcessResponseParams<'a> { content_encoding: &'a str, @@ -291,6 +365,7 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, } /// Process response body through the streaming pipeline. @@ -333,6 +408,7 @@ fn process_response_streaming<W: Write>( params.request_scheme, params.settings, params.integration_registry, + params.ad_slots_script, )?; StreamingPipeline::new(config, processor).process(body, output)?; } else if is_rsc_flight { @@ -363,16 +439,18 @@ fn create_html_stream_processor( request_scheme: &str, settings: &Settings, integration_registry: &IntegrationRegistry, + ad_slots_script: Option<&str>, ) -> Result<impl StreamProcessor, Report<TrustedServerError>> { use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - let config = HtmlProcessorConfig::from_settings( + let mut config = HtmlProcessorConfig::from_settings( settings, integration_registry, origin_host, request_host, request_scheme, ); + config.ad_slots_script = ad_slots_script.map(str::to_string); Ok(create_html_processor(config)) } @@ -399,6 +477,8 @@ pub enum PublisherResponse { body: Body, /// Parameters for `process_response_streaming`. params: OwnedProcessResponseParams, + /// Optional server-side ad auction advanced while the body streams. + server_side_auction: Option<Box<ServerSideAuctionStream>>, }, /// Non-processable 2xx response (images, fonts, video). The adapter must /// reattach the body via `response.set_body(body)` before returning. @@ -501,6 +581,137 @@ pub struct OwnedProcessResponseParams { pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, + pub(crate) ad_slots_script: Option<String>, +} + +/// Server-side ad auction state advanced during publisher response streaming. +pub struct ServerSideAuctionStream { + request_id: String, + pending_auction: PendingAuction, + price_granularity: crate::price_bucket::PriceGranularity, + completed: bool, +} + +impl ServerSideAuctionStream { + /// Create stream-owned server-side auction state. + #[must_use] + pub fn new( + request_id: String, + pending_auction: PendingAuction, + price_granularity: crate::price_bucket::PriceGranularity, + ) -> Self { + Self { + request_id, + pending_auction, + price_granularity, + completed: false, + } + } + + fn poll_once(&mut self, services: &RuntimeServices, bid_cache: &dyn BidCache) { + if self.completed { + return; + } + + match futures::executor::block_on(self.pending_auction.poll_once(services)) { + Ok(PendingAuctionPoll::Pending) => {} + Ok(PendingAuctionPoll::Complete(result)) => self.write_result(bid_cache, &result), + Err(error) => { + log::warn!("Server-side ad auction poll failed: {error:?}"); + self.write_empty(bid_cache); + } + } + } + + fn complete_with_collected_bids(&mut self, bid_cache: &dyn BidCache) { + if self.completed { + return; + } + + let result = self.pending_auction.finish_due_to_deadline(); + self.write_result(bid_cache, &result); + } + + fn drain_after_stream(&mut self, services: &RuntimeServices, bid_cache: &dyn BidCache) { + while !self.completed { + match futures::executor::block_on(self.pending_auction.poll_once(services)) { + Ok(PendingAuctionPoll::Pending) => { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + Ok(PendingAuctionPoll::Complete(result)) => self.write_result(bid_cache, &result), + Err(error) => { + log::warn!("Server-side ad auction drain failed: {error:?}"); + self.write_empty(bid_cache); + } + } + } + } + + fn write_result( + &mut self, + bid_cache: &dyn BidCache, + result: &crate::auction::orchestrator::OrchestrationResult, + ) { + let bids = build_bid_map(&result.winning_bids, self.price_granularity); + self.write_bids(bid_cache, bids); + } + + fn write_empty(&mut self, bid_cache: &dyn BidCache) { + self.write_bids(bid_cache, BidMap::new()); + } + + fn write_bids(&mut self, bid_cache: &dyn BidCache, bids: BidMap) { + if let Err(error) = bid_cache.put(&self.request_id, bids) { + log::warn!( + "Failed to write server-side ad bids for request '{}': {error:?}", + self.request_id + ); + } + self.completed = true; + } +} + +struct AuctionPollingWriter<'a, W: Write> { + inner: &'a mut W, + services: &'a RuntimeServices, + bid_cache: &'a dyn BidCache, + server_side_auction: &'a mut ServerSideAuctionStream, +} + +impl<W: Write> AuctionPollingWriter<'_, W> { + fn poll_auction(&mut self) { + self.server_side_auction + .poll_once(self.services, self.bid_cache); + } + + fn finish_auction(&mut self) { + self.server_side_auction + .drain_after_stream(self.services, self.bid_cache); + } +} + +impl<W: Write> Write for AuctionPollingWriter<'_, W> { + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + let bytes_written = self.inner.write(buf)?; + self.poll_auction(); + Ok(bytes_written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush()?; + self.poll_auction(); + Ok(()) + } +} + +/// Server-side auction dependencies used while streaming a publisher body. +pub struct ServerSideAuctionStreamContext<'a> { + /// Runtime services used to poll platform HTTP requests. + pub services: &'a RuntimeServices, + /// Request-scoped bid cache rendezvous. + pub bid_cache: &'a dyn BidCache, + /// Auction state advanced during streaming. + pub server_side_auction: &'a mut ServerSideAuctionStream, } /// Stream the publisher response body through the processing pipeline. @@ -529,10 +740,41 @@ pub fn stream_publisher_body<W: Write>( settings, content_type: ¶ms.content_type, integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), }; process_response_streaming(body, output, &borrowed) } +/// Stream the publisher response body while advancing a server-side ad auction. +/// +/// # Errors +/// +/// Returns an error if body processing fails mid-stream. +pub fn stream_publisher_body_with_ad_auction<W: Write>( + body: Body, + output: &mut W, + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, + auction_context: &mut ServerSideAuctionStreamContext<'_>, +) -> Result<(), Report<TrustedServerError>> { + let mut polling_output = AuctionPollingWriter { + inner: output, + services: auction_context.services, + bid_cache: auction_context.bid_cache, + server_side_auction: &mut *auction_context.server_side_auction, + }; + let result = stream_publisher_body( + body, + &mut polling_output, + params, + settings, + integration_registry, + ); + polling_output.finish_auction(); + result +} + /// Proxies requests to the publisher's origin server. /// /// Returns a [`PublisherResponse`] indicating how the response should be sent: @@ -554,6 +796,9 @@ pub fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, services: &RuntimeServices, + orchestrator: &AuctionOrchestrator, + slots_file: &CreativeOpportunitiesFile, + bid_cache: &dyn BidCache, mut req: Request, ) -> Result<PublisherResponse, Report<TrustedServerError>> { log::debug!("Proxying request to publisher_origin"); @@ -618,6 +863,17 @@ pub fn handle_publisher_request( let ec_allowed = allows_ec_creation(&consent_context); log::debug!("Proxy ec_allowed: {}", ec_allowed); + let request_path = req.get_path().to_string(); + let auction_context_request = clone_request_metadata_for_auction(&req); + let creative_opportunities = settings.creative_opportunities.as_ref(); + let matched_slots = creative_opportunities + .map(|_| match_slots(&slots_file.slots, &request_path)) + .unwrap_or_default(); + let slots_matched = !matched_slots.is_empty(); + let auction_timeout_ms = creative_opportunities + .and_then(|config| config.auction_timeout_ms) + .unwrap_or(settings.auction.timeout_ms); + let backend_name = BackendConfig::from_url( &settings.publisher.origin_url, settings.proxy.certificate_check, @@ -633,8 +889,94 @@ pub fn handle_publisher_request( 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 dispatch publisher origin request".to_string(), + })?; + + let mut ad_slots_script = None; + let mut server_side_auction = None; + if let Some(co_config) = creative_opportunities { + if slots_matched && server_side_auction_allowed(&consent_context) { + let request_id = uuid::Uuid::new_v4().to_string(); + let auction_deadline = crate::bid_cache::AuctionDeadline::from_timeout( + std::time::Duration::from_millis(u64::from(auction_timeout_ms)), + ); + + match bid_cache.mark_pending(&request_id, auction_deadline) { + Ok(()) => { + ad_slots_script = Some(build_head_globals_script( + &matched_slots, + &request_id, + co_config, + )); + + match build_server_side_auction_request(ServerSideAuctionRequestParams { + request_id: &request_id, + matched_slots: &matched_slots, + settings, + services, + req: &auction_context_request, + consent_context: &consent_context, + ec_id: &ec_id, + geo: geo.clone(), + }) { + Ok(auction_request) => { + let context = AuctionContext { + settings, + request: &auction_context_request, + client_info: services.client_info(), + timeout_ms: auction_timeout_ms, + provider_responses: None, + services, + }; + + match orchestrator.start_server_side_auction(auction_request, &context) + { + Ok(pending_auction) => { + server_side_auction = Some(ServerSideAuctionStream::new( + request_id, + pending_auction, + co_config.price_granularity, + )); + } + Err(error) => { + log::warn!("Failed to start server-side ad auction: {error:?}"); + if let Err(cache_error) = + bid_cache.put(&request_id, BidMap::new()) + { + log::warn!( + "Failed to complete failed server-side ad auction request '{}': {cache_error:?}", + request_id + ); + } + } + } + } + Err(error) => { + log::warn!("Failed to build server-side ad auction request: {error:?}"); + if let Err(cache_error) = bid_cache.put(&request_id, BidMap::new()) { + log::warn!( + "Failed to complete failed server-side ad auction request '{}': {cache_error:?}", + request_id + ); + } + } + } + } + Err(error) => { + log::warn!( + "Failed to mark server-side ad auction pending for path '{}': {error:?}", + request_path + ); + } + } + } + } + + let mut response = pending_origin + .wait() .change_context(TrustedServerError::Proxy { message: "Failed to proxy request to origin".to_string(), })?; @@ -655,6 +997,7 @@ pub fn handle_publisher_request( existing_ec_cookie.as_deref(), &consent_context, ); + apply_server_side_ad_cache_policy(&mut response, slots_matched, ad_slots_script.is_some()); let content_type = response .get_header(header::CONTENT_TYPE) @@ -685,6 +1028,9 @@ pub fn handle_publisher_request( content_type, status, ); + if let Some(mut auction) = server_side_auction { + auction.complete_with_collected_bids(bid_cache); + } let body = response.take_body(); Ok(PublisherResponse::PassThrough { response, body }) } @@ -711,6 +1057,9 @@ pub fn handle_publisher_request( status, ); } + if let Some(mut auction) = server_side_auction { + auction.complete_with_collected_bids(bid_cache); + } Ok(PublisherResponse::Buffered(response)) } ResponseRoute::Stream => { @@ -732,7 +1081,9 @@ 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(), }, + server_side_auction: server_side_auction.map(Box::new), }) } ResponseRoute::BufferedProcessed => { @@ -751,9 +1102,13 @@ pub fn handle_publisher_request( settings, content_type: &content_type, integration_registry, + ad_slots_script: ad_slots_script.as_deref(), }; let mut output = Vec::new(); process_response_streaming(body, &mut output, ¶ms)?; + if let Some(mut auction) = server_side_auction { + auction.complete_with_collected_bids(bid_cache); + } response.set_header(header::CONTENT_LENGTH, output.len().to_string()); response.set_body(Body::from(output)); @@ -828,7 +1183,11 @@ fn apply_ec_headers( #[cfg(test)] mod creative_opportunities_tests { use super::*; - use crate::auction::types::{Bid, MediaType}; + use crate::auction::orchestrator::{OrchestrationResult, PendingAuction}; + use crate::auction::types::{ + AuctionRequest, AuctionResponse, Bid, MediaType, PublisherInfo, UserInfo, + }; + use crate::bid_cache::{AuctionDeadline, CacheResult, InMemoryBidCache}; use crate::consent::{ConsentContext, TcfConsent}; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, @@ -891,6 +1250,25 @@ mod creative_opportunities_tests { } } + fn make_auction_request() -> AuctionRequest { + AuctionRequest { + id: "auction-1".to_string(), + slots: Vec::new(), + publisher: PublisherInfo { + domain: "test-publisher.com".to_string(), + page_url: Some("https://test-publisher.com/news/story".to_string()), + }, + user: UserInfo { + id: "ec-id".to_string(), + fresh_id: "fresh-id".to_string(), + consent: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + fn make_tcf_with_purpose_1(consented: bool) -> TcfConsent { TcfConsent { version: 2, @@ -1801,6 +2179,7 @@ mod creative_opportunities_tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + ad_slots_script: None, }; let mut output = Vec::new(); @@ -1842,6 +2221,7 @@ mod creative_opportunities_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, }; let mut output = Vec::new(); @@ -1854,6 +2234,186 @@ mod creative_opportunities_tests { ); } + #[test] + fn stream_publisher_body_injects_ad_slots_script_at_head_open() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: Some( + "<script>window.__ts_ad_slots=[];window.__ts_request_id=\"rid-1\";</script>" + .to_string(), + ), + }; + + let mut output = Vec::new(); + stream_publisher_body( + Body::from("<html><head><title>Test"), + &mut output, + ¶ms, + &settings, + ®istry, + ) + .expect("should process HTML body"); + let html = String::from_utf8(output).expect("should emit UTF-8 HTML"); + + assert!( + html.starts_with(""), + "should inject ad globals immediately after the head open tag: {html}" + ); + assert!( + !html.contains("window.__ts_bids"), + "should never inject bid data" + ); + } + + #[test] + fn stream_publisher_body_with_ad_auction_writes_completed_bid_map() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let services = noop_services(); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + 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, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-stream", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: vec![AuctionResponse::success( + "prebid", + vec![make_bid("atf_sidebar", Some(1.239), Some("ad-123"))], + 8, + )], + mediator_response: None, + winning_bids: HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]), + total_time_ms: 8, + metadata: HashMap::new(), + }; + let pending_auction = + PendingAuction::from_completed_result_for_test(make_auction_request(), auction_result); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-stream".to_string(), + pending_auction, + PriceGranularity::Dense, + ); + + let mut output = Vec::new(); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + stream_publisher_body_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &settings, + ®istry, + &mut auction_context, + ) + .expect("should stream body and poll auction"); + + match cache.try_get("rid-stream").expect("should read bid cache") { + CacheResult::Complete { bids } => { + assert_eq!( + bids.get("atf_sidebar"), + Some(&json!({ + "hb_pb": "1.23", + "hb_bidder": "rubicon", + "hb_adid": "ad-123", + "burl": "https://bidder.example/bill", + })), + "should write GPT bid targeting into cache" + ); + } + other => panic!("should complete bid cache, got {other:?}"), + } + } + + #[test] + fn build_server_side_auction_request_uses_matched_slots_and_request_context() { + let settings = create_test_settings(); + let services = noop_services(); + let slot = make_creative_slot("atf_sidebar"); + let matched_slots = vec![&slot]; + let consent_context = ConsentContext::default(); + let mut req = Request::get("https://test-publisher.com/news/story?utm=1"); + req.set_header(header::USER_AGENT, "Mozilla/5.0 Test"); + + let auction_request = build_server_side_auction_request(ServerSideAuctionRequestParams { + request_id: "rid-article", + matched_slots: &matched_slots, + settings: &settings, + services: &services, + req: &req, + consent_context: &consent_context, + ec_id: "ec-id-1", + geo: None, + }) + .expect("should build auction request"); + + assert_eq!( + auction_request.id, "rid-article", + "should use the request ID as the auction ID" + ); + assert_eq!( + auction_request.slots.len(), + 1, + "should include matched creative slots only" + ); + assert_eq!( + auction_request.slots[0].id, "atf_sidebar", + "should convert creative slot to auction slot" + ); + assert_eq!( + auction_request.slots[0].floor_price, + Some(1.25), + "should preserve slot floor" + ); + assert_eq!( + auction_request.publisher.page_url.as_deref(), + Some("https://test-publisher.com/news/story?utm=1"), + "should use the actual page URL" + ); + assert_eq!( + auction_request.user.id, "ec-id-1", + "should use the request EC ID" + ); + assert!( + auction_request.user.consent.is_some(), + "should carry consent into provider requests" + ); + assert_eq!( + auction_request + .device + .as_ref() + .and_then(|device| device.user_agent.as_deref()), + Some("Mozilla/5.0 Test"), + "should forward user-agent in device context" + ); + } + /// Mid-stream decoder failure must surface as an error. The adapter /// relies on this: once headers are committed, it logs and drops the /// `StreamingBody` so the client sees a truncated response. If a decode @@ -1874,6 +2434,7 @@ mod creative_opportunities_tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, }; let bogus_body = Body::from(b"not gzip".to_vec()); @@ -1973,6 +2534,7 @@ mod creative_opportunities_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, }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -2023,6 +2585,7 @@ mod creative_opportunities_tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, }; let mut output = Vec::new(); diff --git a/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md index dd0d14a4e..961fafa46 100644 --- a/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md +++ b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md @@ -1030,7 +1030,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID - Modify: `crates/js/lib/src/integrations/gpt/index.ts` - Modify: `crates/js/lib/src/integrations/gpt/index.test.ts` -- [ ] **Step 1: Write failing Rust test** +- [x] **Step 1: Write failing Rust test** In `gpt.rs`, add a test that combines `head_inserts()` output and asserts: - contains `__tsAdInit` @@ -1049,7 +1049,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: failure because `__tsAdInit` is missing. -- [ ] **Step 2: Extend GPT Rust head injector** +- [x] **Step 2: Extend GPT Rust head injector** Keep the existing GPT shim install snippet and add an inline `__tsAdInit` snippet that: - reads `window.__ts_ad_slots || []` @@ -1061,7 +1061,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID - waits for `bidsPromise` before applying `hb_*` targeting and calling `refresh()` - fires `burl` through `navigator.sendBeacon` only after `slotRenderEnded` confirms matching `hb_adid` -- [ ] **Step 3: Write failing TypeScript tests** +- [x] **Step 3: Write failing TypeScript tests** Add tests for: - `/ts-bids?rid=` fetch with `credentials: 'omit'` @@ -1078,11 +1078,11 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: failure because `installTsAdInit` is missing. -- [ ] **Step 4: Implement `installTsAdInit`** +- [x] **Step 4: Implement `installTsAdInit`** In `index.ts`, export `installTsAdInit()` and call it from the integration initialization path. Keep the existing GPT guard behavior. -- [ ] **Step 5: Run tests and build** +- [x] **Step 5: Run tests and build** ```bash cargo test -p trusted-server-core integrations::gpt @@ -1092,7 +1092,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: all pass. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/trusted-server-core/src/integrations/gpt.rs crates/js/lib/src/integrations/gpt @@ -1109,7 +1109,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID This route is Fastly-only for Phase 1. Future adapters can omit this route, return unsupported, or keep server-side ad templates disabled until the EdgeZero migration provides equivalent request-rendezvous semantics. -- [ ] **Step 1: Write failing tests** +- [x] **Step 1: Write failing tests** Add route or helper tests for: - missing `rid` -> `400` @@ -1127,7 +1127,7 @@ This route is Fastly-only for Phase 1. Future adapters can omit this route, retu Expected: failure because handler is missing. -- [ ] **Step 2: Add route** +- [x] **Step 2: Add route** Before the publisher fallback route, add: @@ -1137,7 +1137,7 @@ This route is Fastly-only for Phase 1. Future adapters can omit this route, retu Adjust `route_request` parameters to receive `bid_cache: &BidCache`. -- [ ] **Step 3: Implement handler** +- [x] **Step 3: Implement handler** Handler behavior: - Parse `rid` from query. @@ -1150,7 +1150,7 @@ This route is Fastly-only for Phase 1. Future adapters can omit this route, retu - `WaitResult::NotFound`: return `404`. - Always set `Cache-Control: private, no-store`. -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** ```bash cargo test -p trusted-server-adapter-fastly ts_bids @@ -1158,7 +1158,7 @@ This route is Fastly-only for Phase 1. Future adapters can omit this route, retu Expected: tests pass. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add crates/trusted-server-adapter-fastly/src/main.rs @@ -1179,7 +1179,7 @@ This route is Fastly-only for Phase 1. Future adapters can omit this route, retu Do not start this task unless Task 1 has a `Go` decision. Implement exactly the non-blocking shape selected by Task 1. -- [ ] **Step 1: Write failing integration tests** +- [x] **Step 1: Write failing integration tests** Add tests proving: - platform HTTP client exposes a non-blocking `poll` operation for pending requests @@ -1201,7 +1201,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the Expected: failures for missing integration. -- [ ] **Step 2: Add platform pending-request polling** +- [x] **Step 2: Add platform pending-request polling** Extend `PlatformHttpClient` with a non-blocking poll method: @@ -1218,7 +1218,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the In the Fastly adapter, implement it with `fastly::http::request::PollResult` from `PendingRequest::poll()`. Non-Fastly/test implementations may return `PlatformError::Unsupported` until EdgeZero migration adds equivalent primitives. -- [ ] **Step 3: Add pollable auction progression** +- [x] **Step 3: Add pollable auction progression** Refactor auction orchestration without changing existing `/auction` behavior: - Keep `AuctionOrchestrator::run_auction` for existing endpoints. @@ -1229,7 +1229,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the This is the mechanism that lets publisher streaming continue while auction work advances opportunistically between streaming chunks. -- [ ] **Step 4: Load creative opportunities and bid cache in adapter** +- [x] **Step 4: Load creative opportunities and bid cache in adapter** In `main.rs`, add: @@ -1253,7 +1253,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the The Fastly bid cache itself should be a lightweight value over Core Cache APIs and may be constructed per request because the state lives in Fastly Core Cache, not the Rust object. -- [ ] **Step 5: Update publisher handler signature** +- [x] **Step 5: Update publisher handler signature** Add the dependencies required by the selected Task 1 shape: - `orchestrator: &AuctionOrchestrator` @@ -1262,7 +1262,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the Keep `AuctionContext` construction aligned with current code: include `settings`, `request`, `client_info`, `timeout_ms`, `provider_responses`, and `services`. -- [ ] **Step 6: Match slots and decide consent before origin body processing** +- [x] **Step 6: Match slots and decide consent before origin body processing** Required behavior: - Mint `request_id` only when slots match. @@ -1271,7 +1271,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the - If slots match but consent is denied/absent, do not run auction and do not inject globals; set browser `Cache-Control: private, no-store`. - If slots match and consent allows, register pending cache entry with `A_deadline`, inject globals, and dispatch auction through the Task 1 verified non-blocking path. -- [ ] **Step 7: Preserve streaming invariant** +- [x] **Step 7: Preserve streaming invariant** The implementation must satisfy: - Origin request is dispatched immediately. @@ -1280,14 +1280,14 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the - During body streaming, auction work may only use non-blocking `poll` calls between chunk writes. - If the auction completes after page streaming starts, it writes bid results to `BidCache`. -- [ ] **Step 8: Force chunked browser response for processed HTML** +- [x] **Step 8: Force chunked browser response for processed HTML** For responses that enter the HTML processing path: - Remove `Content-Length`. - Set `Transfer-Encoding: chunked` if Fastly permits it explicitly. - Do not force chunked on binary pass-through responses. -- [ ] **Step 9: Run tests** +- [x] **Step 9: Run tests** ```bash cargo test -p trusted-server-core publisher @@ -1296,7 +1296,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the Expected: tests pass. -- [ ] **Step 10: Commit** +- [x] **Step 10: Commit** ```bash git add crates/trusted-server-core/src/platform/http.rs crates/trusted-server-adapter-fastly/src/platform.rs \ From 6a76686db4088f3ef98ddf1cf7b8c46c6e5016dc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 4 May 2026 08:00:21 +0530 Subject: [PATCH 19/24] Fire Prebid nurls from the edge --- .../src/integrations/prebid.rs | 19 ++ crates/trusted-server-core/src/publisher.rs | 194 ++++++++++++++++-- .../2026-04-30-server-side-ad-templates.md | 10 +- 3 files changed, 206 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d31c4b89f..73f8d96b9 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -119,6 +119,10 @@ pub struct PrebidIntegrationConfig { /// - `both` — consent in both cookies and body (default) #[serde(default)] pub consent_forwarding: ConsentForwardingMode, + /// Whether the edge should fire Prebid win-notification URLs after + /// server-side template auction completion. + #[serde(default = "default_fire_nurl_at_edge")] + pub fire_nurl_at_edge: bool, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -139,6 +143,10 @@ fn default_enabled() -> bool { true } +fn default_fire_nurl_at_edge() -> bool { + true +} + /// Default suffixes that identify Prebid scripts const PREBID_SCRIPT_SUFFIXES: &[&str] = &[ "/prebid.js", @@ -1265,9 +1273,20 @@ mod tests { client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::new(), consent_forwarding: ConsentForwardingMode::Both, + fire_nurl_at_edge: true, } } + #[test] + fn prebid_fire_nurl_at_edge_defaults_to_true() { + let config = base_config(); + + assert!( + config.fire_nurl_at_edge, + "should default to edge nurl firing" + ); + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 62961991e..092acff82 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -14,6 +14,8 @@ use std::collections::HashMap; use std::io::{self, Write}; +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::request_builder; use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; @@ -34,8 +36,9 @@ use crate::edge_cookie::{generate_ec_id, get_or_generate_ec_id}; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::http_util::{serve_static_with_etag, RequestInfo}; +use crate::integrations::prebid::PrebidIntegrationConfig; use crate::integrations::IntegrationRegistry; -use crate::platform::RuntimeServices; +use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -290,6 +293,69 @@ fn apply_server_side_ad_cache_policy( } } +fn prebid_fire_nurl_at_edge(settings: &Settings) -> bool { + match settings.integration_config::("prebid") { + Ok(Some(config)) => config.fire_nurl_at_edge, + Ok(None) => true, + Err(error) => { + log::warn!("Failed to read Prebid fire_nurl_at_edge setting: {error:?}"); + true + } + } +} + +fn fire_prebid_nurls_at_edge( + services: &RuntimeServices, + winning_bids: &HashMap, + certificate_check: bool, +) { + for bid in winning_bids.values() { + let Some(nurl) = bid.nurl.as_deref() else { + continue; + }; + + let backend_name = match BackendConfig::from_url(nurl, certificate_check) { + Ok(name) => name, + Err(error) => { + log::warn!( + "Failed to build backend for Prebid nurl '{}' from bidder '{}': {error:?}", + nurl, + bid.bidder + ); + continue; + } + }; + + let request = match request_builder() + .method("GET") + .uri(nurl) + .body(EdgeBody::empty()) + { + Ok(request) => request, + Err(error) => { + log::warn!( + "Failed to build Prebid nurl request '{}' from bidder '{}': {error}", + nurl, + bid.bidder + ); + continue; + } + }; + + if let Err(error) = futures::executor::block_on( + services + .http_client() + .send_async(PlatformHttpRequest::new(request, backend_name)), + ) { + log::warn!( + "Failed to dispatch Prebid nurl '{}' from bidder '{}': {error:?}", + nurl, + bid.bidder + ); + } + } +} + pub(crate) struct ServerSideAuctionRequestParams<'a> { request_id: &'a str, matched_slots: &'a [&'a CreativeOpportunitySlot], @@ -589,6 +655,8 @@ pub struct ServerSideAuctionStream { request_id: String, pending_auction: PendingAuction, price_granularity: crate::price_bucket::PriceGranularity, + fire_nurl_at_edge: bool, + certificate_check: bool, completed: bool, } @@ -599,11 +667,15 @@ impl ServerSideAuctionStream { request_id: String, pending_auction: PendingAuction, price_granularity: crate::price_bucket::PriceGranularity, + fire_nurl_at_edge: bool, + certificate_check: bool, ) -> Self { Self { request_id, pending_auction, price_granularity, + fire_nurl_at_edge, + certificate_check, completed: false, } } @@ -615,7 +687,9 @@ impl ServerSideAuctionStream { match futures::executor::block_on(self.pending_auction.poll_once(services)) { Ok(PendingAuctionPoll::Pending) => {} - Ok(PendingAuctionPoll::Complete(result)) => self.write_result(bid_cache, &result), + Ok(PendingAuctionPoll::Complete(result)) => { + self.write_result(services, bid_cache, &result); + } Err(error) => { log::warn!("Server-side ad auction poll failed: {error:?}"); self.write_empty(bid_cache); @@ -623,13 +697,17 @@ impl ServerSideAuctionStream { } } - fn complete_with_collected_bids(&mut self, bid_cache: &dyn BidCache) { + fn complete_with_collected_bids( + &mut self, + services: &RuntimeServices, + bid_cache: &dyn BidCache, + ) { if self.completed { return; } let result = self.pending_auction.finish_due_to_deadline(); - self.write_result(bid_cache, &result); + self.write_result(services, bid_cache, &result); } fn drain_after_stream(&mut self, services: &RuntimeServices, bid_cache: &dyn BidCache) { @@ -638,7 +716,9 @@ impl ServerSideAuctionStream { Ok(PendingAuctionPoll::Pending) => { std::thread::sleep(std::time::Duration::from_millis(1)); } - Ok(PendingAuctionPoll::Complete(result)) => self.write_result(bid_cache, &result), + Ok(PendingAuctionPoll::Complete(result)) => { + self.write_result(services, bid_cache, &result); + } Err(error) => { log::warn!("Server-side ad auction drain failed: {error:?}"); self.write_empty(bid_cache); @@ -649,25 +729,32 @@ impl ServerSideAuctionStream { fn write_result( &mut self, + services: &RuntimeServices, bid_cache: &dyn BidCache, result: &crate::auction::orchestrator::OrchestrationResult, ) { let bids = build_bid_map(&result.winning_bids, self.price_granularity); - self.write_bids(bid_cache, bids); + let wrote_bids = self.write_bids(bid_cache, bids); + if wrote_bids && self.fire_nurl_at_edge { + fire_prebid_nurls_at_edge(services, &result.winning_bids, self.certificate_check); + } } fn write_empty(&mut self, bid_cache: &dyn BidCache) { self.write_bids(bid_cache, BidMap::new()); } - fn write_bids(&mut self, bid_cache: &dyn BidCache, bids: BidMap) { - if let Err(error) = bid_cache.put(&self.request_id, bids) { + fn write_bids(&mut self, bid_cache: &dyn BidCache, bids: BidMap) -> bool { + let result = bid_cache.put(&self.request_id, bids); + let wrote_bids = result.is_ok(); + if let Err(error) = result { log::warn!( "Failed to write server-side ad bids for request '{}': {error:?}", self.request_id ); } self.completed = true; + wrote_bids } } @@ -939,6 +1026,8 @@ pub fn handle_publisher_request( request_id, pending_auction, co_config.price_granularity, + prebid_fire_nurl_at_edge(settings), + settings.proxy.certificate_check, )); } Err(error) => { @@ -1029,7 +1118,7 @@ pub fn handle_publisher_request( status, ); if let Some(mut auction) = server_side_auction { - auction.complete_with_collected_bids(bid_cache); + auction.complete_with_collected_bids(services, bid_cache); } let body = response.take_body(); Ok(PublisherResponse::PassThrough { response, body }) @@ -1058,7 +1147,7 @@ pub fn handle_publisher_request( ); } if let Some(mut auction) = server_side_auction { - auction.complete_with_collected_bids(bid_cache); + auction.complete_with_collected_bids(services, bid_cache); } Ok(PublisherResponse::Buffered(response)) } @@ -1107,7 +1196,7 @@ pub fn handle_publisher_request( let mut output = Vec::new(); process_response_streaming(body, &mut output, ¶ms)?; if let Some(mut auction) = server_side_auction { - auction.complete_with_collected_bids(bid_cache); + auction.complete_with_collected_bids(services, bid_cache); } response.set_header(header::CONTENT_LENGTH, output.len().to_string()); @@ -1194,12 +1283,15 @@ mod creative_opportunities_tests { SlotProviders, }; use crate::integrations::IntegrationRegistry; - use crate::platform::test_support::noop_services; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; use crate::price_bucket::PriceGranularity; use crate::test_support::tests::create_test_settings; use fastly::http::{header, Method, StatusCode}; use serde_json::json; use std::collections::HashMap; + use std::sync::Arc; fn make_creative_slot(id: &str) -> CreativeOpportunitySlot { CreativeOpportunitySlot { @@ -2316,6 +2408,8 @@ mod creative_opportunities_tests { "rid-stream".to_string(), pending_auction, PriceGranularity::Dense, + false, + settings.proxy.certificate_check, ); let mut output = Vec::new(); @@ -2351,6 +2445,82 @@ mod creative_opportunities_tests { } } + #[test] + fn stream_publisher_body_with_ad_auction_fires_winning_nurl_when_enabled() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(204, Vec::new()); + let services = build_services_with_http_client(http_client.clone()); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + 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, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-nurl", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: vec![AuctionResponse::success( + "prebid", + vec![make_bid("atf_sidebar", Some(1.239), Some("ad-123"))], + 8, + )], + mediator_response: None, + winning_bids: HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]), + total_time_ms: 8, + metadata: HashMap::new(), + }; + let pending_auction = + PendingAuction::from_completed_result_for_test(make_auction_request(), auction_result); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-nurl".to_string(), + pending_auction, + PriceGranularity::Dense, + true, + settings.proxy.certificate_check, + ); + + let mut output = Vec::new(); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + stream_publisher_body_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &settings, + ®istry, + &mut auction_context, + ) + .expect("should stream body and poll auction"); + + let expected_backend = BackendConfig::from_url( + "https://bidder.example/win", + settings.proxy.certificate_check, + ) + .expect("should build nurl backend"); + assert_eq!( + http_client.recorded_backend_names(), + vec![expected_backend], + "should fire the winning bid nurl through the platform HTTP client" + ); + } + #[test] fn build_server_side_auction_request_uses_matched_slots_and_request_context() { let settings = create_test_settings(); diff --git a/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md index 961fafa46..a24e6f5f8 100644 --- a/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md +++ b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md @@ -1316,7 +1316,7 @@ Do not start this task unless Task 1 has a `Go` decision. Implement exactly the This task implements `nurl` firing only for the Fastly Phase 1 path. Future adapters may return unsupported or disable server-side ad templates until they can provide equivalent non-blocking outbound HTTP behavior. -- [ ] **Step 1: Write failing config test** +- [x] **Step 1: Write failing config test** In Prebid tests, assert: @@ -1341,7 +1341,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap Expected: failure until field/default is implemented. -- [ ] **Step 2: Add config field** +- [x] **Step 2: Add config field** Add to `PrebidIntegrationConfig`: @@ -1358,7 +1358,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap } ``` -- [ ] **Step 3: Fire winning nurls after auction completion** +- [x] **Step 3: Fire winning nurls after auction completion** In the selected non-blocking auction completion path, after writing bid results to `BidCache`, call a helper that: - Reads `PrebidIntegrationConfig` via `settings.integrations.get_typed::("prebid")`. @@ -1366,7 +1366,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap - Uses the Fastly-supported async HTTP primitive from Task 1, for example `fastly::Request::get(nurl).send_async(&backend_name)`, for each winning bid with `nurl`. - Logs warnings but never fails the page or `/ts-bids`. -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** ```bash cargo test -p trusted-server-core integrations::prebid publisher @@ -1374,7 +1374,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap Expected: tests pass. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add crates/trusted-server-core/src/integrations/prebid.rs crates/trusted-server-core/src/publisher.rs From 01cbbe0309356da382197fbf68b77500a3edf051 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 4 May 2026 08:52:51 +0530 Subject: [PATCH 20/24] Avoid nested executor while polling ad auctions --- .../src/platform.rs | 14 ++ .../src/auction/orchestrator.rs | 146 ++++++++++++------ .../trusted-server-core/src/platform/http.rs | 28 ++++ .../src/platform/test_support.rs | 14 ++ crates/trusted-server-core/src/publisher.rs | 73 ++++++++- 5 files changed, 218 insertions(+), 57 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 946b07ebd..aa72bfe95 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -255,6 +255,13 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { async fn send_async( &self, request: PlatformHttpRequest, + ) -> Result> { + self.send_async_now(request) + } + + fn send_async_now( + &self, + request: PlatformHttpRequest, ) -> Result> { let backend_name = request.backend_name.clone(); let fastly_req = edge_request_to_fastly(request.request)?; @@ -320,6 +327,13 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { async fn poll( &self, pending: PlatformPendingRequest, + ) -> Result> { + self.poll_now(pending) + } + + fn poll_now( + &self, + pending: PlatformPendingRequest, ) -> Result> { let backend_name = pending.backend_name().map(str::to_string); let inner = pending.downcast::().map_err(|platform_req| { diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index cfe239f2c..c9ee7522e 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -148,58 +148,58 @@ impl PendingAuction { ) -> Result> { let mut still_pending = Vec::with_capacity(self.pending.len()); - for pending_provider in self.pending.drain(..) { - match services - .http_client() - .poll(pending_provider.pending) - .await - .change_context(TrustedServerError::Auction { - message: format!( - "HTTP poll failed for provider '{}'", - pending_provider.provider.provider_name() - ), - })? { - PlatformPollResult::Pending(pending) => { - still_pending.push(PendingProviderRequest { - pending, - ..pending_provider - }); - } - PlatformPollResult::Ready(Ok(platform_response)) => { - let response_time_ms = pending_provider.started_at.elapsed().as_millis() as u64; - match platform_response_to_fastly(platform_response).and_then(|response| { - pending_provider.provider.parse_response_for_request( - response, - response_time_ms, - &self.request, - ) - }) { - Ok(response) => self.provider_responses.push(response), - Err(error) => { - log::warn!( - "Provider '{}' failed during non-blocking auction poll: {error:?}", - pending_provider.provider.provider_name() - ); - self.provider_responses.push(AuctionResponse::error( - pending_provider.provider.provider_name(), - response_time_ms, - )); - } - } - } - PlatformPollResult::Ready(Err(error)) => { - log::warn!( - "Provider '{}' poll completed with error: {error:?}", - pending_provider.provider.provider_name() - ); - self.provider_responses.push(AuctionResponse::error( - pending_provider.provider.provider_name(), - pending_provider.started_at.elapsed().as_millis() as u64, - )); - } - } + for pending_provider in std::mem::take(&mut self.pending) { + let PendingProviderRequest { + provider, + started_at, + pending, + } = pending_provider; + let provider_name = provider.provider_name(); + let poll_result = services.http_client().poll(pending).await.change_context( + TrustedServerError::Auction { + message: format!("HTTP poll failed for provider '{provider_name}'"), + }, + )?; + self.record_poll_result(provider, started_at, poll_result, &mut still_pending); + } + + self.finish_poll_round(still_pending) + } + + /// Advance provider requests once without blocking from a synchronous call + /// site. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the platform poll operation fails. + pub fn poll_once_now( + &mut self, + services: &RuntimeServices, + ) -> Result> { + let mut still_pending = Vec::with_capacity(self.pending.len()); + + for pending_provider in std::mem::take(&mut self.pending) { + let PendingProviderRequest { + provider, + started_at, + pending, + } = pending_provider; + let provider_name = provider.provider_name(); + let poll_result = services.http_client().poll_now(pending).change_context( + TrustedServerError::Auction { + message: format!("HTTP poll failed for provider '{provider_name}'"), + }, + )?; + self.record_poll_result(provider, started_at, poll_result, &mut still_pending); } + self.finish_poll_round(still_pending) + } + + fn finish_poll_round( + &mut self, + still_pending: Vec, + ) -> Result> { self.pending = still_pending; if self.pending.is_empty() || Instant::now() >= self.auction_deadline { @@ -209,6 +209,52 @@ impl PendingAuction { } } + fn record_poll_result( + &mut self, + provider: Arc, + started_at: Instant, + poll_result: PlatformPollResult, + still_pending: &mut Vec, + ) { + match poll_result { + PlatformPollResult::Pending(pending) => { + still_pending.push(PendingProviderRequest { + provider, + started_at, + pending, + }); + } + PlatformPollResult::Ready(Ok(platform_response)) => { + let response_time_ms = started_at.elapsed().as_millis() as u64; + match platform_response_to_fastly(platform_response).and_then(|response| { + provider.parse_response_for_request(response, response_time_ms, &self.request) + }) { + Ok(response) => self.provider_responses.push(response), + Err(error) => { + log::warn!( + "Provider '{}' failed during non-blocking auction poll: {error:?}", + provider.provider_name() + ); + self.provider_responses.push(AuctionResponse::error( + provider.provider_name(), + response_time_ms, + )); + } + } + } + PlatformPollResult::Ready(Err(error)) => { + log::warn!( + "Provider '{}' poll completed with error: {error:?}", + provider.provider_name() + ); + self.provider_responses.push(AuctionResponse::error( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + } + } + } + /// Finish the auction using responses collected so far. #[must_use] pub fn finish_due_to_deadline(&self) -> OrchestrationResult { diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index 0b1013234..bbc9acd34 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -192,6 +192,20 @@ pub trait PlatformHttpClient: Send + Sync { request: PlatformHttpRequest, ) -> Result>; + /// Start an upstream request without waiting for it to complete from a + /// synchronous call site. + /// + /// # Errors + /// + /// Returns `PlatformError::Unsupported` when the platform adapter does not + /// provide a synchronous fire-and-forget start primitive. + fn send_async_now( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + /// Wait for one of the in-flight requests to complete. /// /// # Errors @@ -210,6 +224,20 @@ pub trait PlatformHttpClient: Send + Sync { /// Returns `PlatformError::Unsupported` when the platform adapter does not /// provide non-blocking polling. async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result> { + self.poll_now(pending) + } + + /// Poll a single in-flight request without blocking from a synchronous call + /// site. + /// + /// # Errors + /// + /// Returns `PlatformError::Unsupported` when the platform adapter does not + /// provide non-blocking polling. + fn poll_now( &self, _pending: PlatformPendingRequest, ) -> Result> { diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index b06423f6d..a241f7b96 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -298,6 +298,13 @@ impl PlatformHttpClient for StubHttpClient { async fn send_async( &self, request: PlatformHttpRequest, + ) -> Result> { + self.send_async_now(request) + } + + fn send_async_now( + &self, + request: PlatformHttpRequest, ) -> Result> { let backend_name = request.backend_name.clone(); self.calls @@ -359,6 +366,13 @@ impl PlatformHttpClient for StubHttpClient { async fn poll( &self, pending: PlatformPendingRequest, + ) -> Result> { + self.poll_now(pending) + } + + fn poll_now( + &self, + pending: PlatformPendingRequest, ) -> Result> { let stub = pending.downcast::().map_err(|_| { Report::new(PlatformError::HttpClient) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 092acff82..5e7f92d3c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -342,11 +342,10 @@ fn fire_prebid_nurls_at_edge( } }; - if let Err(error) = futures::executor::block_on( - services - .http_client() - .send_async(PlatformHttpRequest::new(request, backend_name)), - ) { + if let Err(error) = services + .http_client() + .send_async_now(PlatformHttpRequest::new(request, backend_name)) + { log::warn!( "Failed to dispatch Prebid nurl '{}' from bidder '{}': {error:?}", nurl, @@ -685,7 +684,7 @@ impl ServerSideAuctionStream { return; } - match futures::executor::block_on(self.pending_auction.poll_once(services)) { + match self.pending_auction.poll_once_now(services) { Ok(PendingAuctionPoll::Pending) => {} Ok(PendingAuctionPoll::Complete(result)) => { self.write_result(services, bid_cache, &result); @@ -712,7 +711,7 @@ impl ServerSideAuctionStream { fn drain_after_stream(&mut self, services: &RuntimeServices, bid_cache: &dyn BidCache) { while !self.completed { - match futures::executor::block_on(self.pending_auction.poll_once(services)) { + match self.pending_auction.poll_once_now(services) { Ok(PendingAuctionPoll::Pending) => { std::thread::sleep(std::time::Duration::from_millis(1)); } @@ -2445,6 +2444,66 @@ mod creative_opportunities_tests { } } + #[test] + fn stream_publisher_body_with_ad_auction_runs_inside_existing_executor() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let services = noop_services(); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + 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, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-existing-executor", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + }; + let pending_auction = PendingAuction::from_completed_result_for_test( + make_auction_request(), + auction_result, + ); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-existing-executor".to_string(), + pending_auction, + PriceGranularity::Dense, + false, + settings.proxy.certificate_check, + ); + let mut output = Vec::new(); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + + stream_publisher_body_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &settings, + ®istry, + &mut auction_context, + ) + .expect("should not nest a local executor while streaming"); + }); + } + #[test] fn stream_publisher_body_with_ad_auction_fires_winning_nurl_when_enabled() { let settings = create_test_settings(); From 533bb3639fa6e0fec1809ce08e3f126341076f4b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 4 May 2026 12:41:01 +0530 Subject: [PATCH 21/24] Address review findings for ad templates --- crates/js/lib/src/integrations/gpt/index.ts | 4 + .../lib/test/integrations/gpt/index.test.ts | 30 ++++ .../src/auction/orchestrator.rs | 35 ++++ .../src/integrations/gpt.rs | 6 +- crates/trusted-server-core/src/publisher.rs | 162 +++++++++++++++--- 5 files changed, 215 insertions(+), 22 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 2ac58ac1f..f6563318f 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -39,6 +39,7 @@ interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; + disableInitialLoad(): void; addEventListener( eventName: 'slotRenderEnded', callback: (event: SlotRenderEndedEvent) => void @@ -272,6 +273,9 @@ function runTsAdInit(win: GptWindow): void { definedSlots.push({ descriptor, slot }); } + if (typeof pubads.disableInitialLoad === 'function') { + pubads.disableInitialLoad(); + } tag.enableServices(); for (const { descriptor } of definedSlots) { diff --git a/crates/js/lib/test/integrations/gpt/index.test.ts b/crates/js/lib/test/integrations/gpt/index.test.ts index 8685ba2bc..8ba761351 100644 --- a/crates/js/lib/test/integrations/gpt/index.test.ts +++ b/crates/js/lib/test/integrations/gpt/index.test.ts @@ -60,6 +60,9 @@ function createGptHarness(win: TsAdInitWindow) { slotRenderEnded = callback; } }), + disableInitialLoad: vi.fn(() => { + operations.push('disableInitialLoad'); + }), refresh: vi.fn(() => { operations.push('refresh'); }), @@ -357,6 +360,33 @@ describe('GPT shim – __tsAdInit bootstrap', () => { } }); + it('disables initial load before display so GPT waits for refresh', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => Promise.resolve(jsonResponse({}))) as unknown as typeof fetch; + const { operations } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(operations.indexOf('disableInitialLoad')).toBeGreaterThanOrEqual(0); + expect(operations.indexOf('disableInitialLoad')).toBeLessThan( + operations.indexOf('display:div-atf-sidebar') + ); + expect(operations.indexOf('display:div-atf-sidebar')).toBeLessThan( + operations.indexOf('refresh') + ); + }); + it('refreshes GPT slots when bid fetch fails', async () => { win.__ts_ad_slots = [ { diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index c9ee7522e..93cd3f399 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -358,6 +358,12 @@ impl AuctionOrchestrator { request: AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + if self.config.has_mediator() { + return Err(Report::new(TrustedServerError::Auction { + message: "server-side template auctions do not support mediation yet; disable the mediator for this Fastly Phase 1 path".to_string(), + })); + } + let auction_started_at = Instant::now(); let auction_deadline = auction_started_at .checked_add(Duration::from_millis(u64::from(context.timeout_ms))) @@ -1172,6 +1178,35 @@ mod tests { ); } + #[test] + fn server_side_auction_rejects_mediator_config_instead_of_bypassing_it() { + let config = AuctionConfig { + enabled: true, + providers: Vec::new(), + mediator: Some("gam".to_string()), + timeout_ms: 2000, + creative_store: "creative_store".to_string(), + allowed_context_keys: HashSet::new(), + }; + let orchestrator = AuctionOrchestrator::new(config); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = Request::get("https://test.com/test"); + let context = create_test_auction_context(&settings, &req, &EMPTY_CLIENT_INFO, 2000); + + let error = match orchestrator.start_server_side_auction(request, &context) { + Ok(_) => { + panic!("mediated server-side template auctions should be explicit unsupported") + } + Err(error) => error, + }; + + assert!( + error.to_string().contains("mediation"), + "should not silently run the non-mediated pending-auction path" + ); + } + // TODO: Re-enable provider integration tests after implementing mock support // for send_async(). Mock providers can't create PendingRequest without real // Fastly backends. diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 6be3040b5..ac827c663 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -64,7 +64,7 @@ const ROUTE_PREFIX: &str = "/integrations/gpt"; const GPT_SHIM_INSTALL_SNIPPET: &str = ""; -const TS_AD_INIT_SNIPPET: &str = r#""#; +const TS_AD_INIT_SNIPPET: &str = r#""#; /// Configuration for the Google Publisher Tags integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -1062,6 +1062,10 @@ mod tests { combined.contains("bidsPromise"), "should create a bid fetch promise" ); + assert!( + combined.contains("disableInitialLoad"), + "should disable GPT initial load so display does not request ads before bid targeting" + ); assert!( combined.contains("slotRenderEnded"), "should listen for rendered GPT slots" diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5e7f92d3c..540e1c9ca 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -790,6 +790,12 @@ impl Write for AuctionPollingWriter<'_, W> { } } +#[derive(Clone, Copy)] +enum AuctionFinishMode { + DrainAfterStream, + CompleteCollected, +} + /// Server-side auction dependencies used while streaming a publisher body. pub struct ServerSideAuctionStreamContext<'a> { /// Runtime services used to poll platform HTTP requests. @@ -831,6 +837,29 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed) } +fn process_response_streaming_with_ad_auction( + body: Body, + output: &mut W, + params: &ProcessResponseParams<'_>, + auction_context: &mut ServerSideAuctionStreamContext<'_>, + finish_mode: AuctionFinishMode, +) -> Result<(), Report> { + let mut polling_output = AuctionPollingWriter { + inner: output, + services: auction_context.services, + bid_cache: auction_context.bid_cache, + server_side_auction: &mut *auction_context.server_side_auction, + }; + let result = process_response_streaming(body, &mut polling_output, params); + match finish_mode { + AuctionFinishMode::DrainAfterStream => polling_output.finish_auction(), + AuctionFinishMode::CompleteCollected => polling_output + .server_side_auction + .complete_with_collected_bids(auction_context.services, auction_context.bid_cache), + } + result +} + /// Stream the publisher response body while advancing a server-side ad auction. /// /// # Errors @@ -844,21 +873,24 @@ pub fn stream_publisher_body_with_ad_auction( integration_registry: &IntegrationRegistry, auction_context: &mut ServerSideAuctionStreamContext<'_>, ) -> Result<(), Report> { - let mut polling_output = AuctionPollingWriter { - inner: output, - services: auction_context.services, - bid_cache: auction_context.bid_cache, - server_side_auction: &mut *auction_context.server_side_auction, - }; - let result = stream_publisher_body( - body, - &mut polling_output, - params, + let borrowed = ProcessResponseParams { + content_encoding: ¶ms.content_encoding, + origin_host: ¶ms.origin_host, + origin_url: ¶ms.origin_url, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, settings, + content_type: ¶ms.content_type, integration_registry, - ); - polling_output.finish_auction(); - result + ad_slots_script: params.ad_slots_script.as_deref(), + }; + process_response_streaming_with_ad_auction( + body, + output, + &borrowed, + auction_context, + AuctionFinishMode::DrainAfterStream, + ) } /// Proxies requests to the publisher's origin server. @@ -992,12 +1024,6 @@ pub fn handle_publisher_request( match bid_cache.mark_pending(&request_id, auction_deadline) { Ok(()) => { - ad_slots_script = Some(build_head_globals_script( - &matched_slots, - &request_id, - co_config, - )); - match build_server_side_auction_request(ServerSideAuctionRequestParams { request_id: &request_id, matched_slots: &matched_slots, @@ -1021,6 +1047,11 @@ pub fn handle_publisher_request( match orchestrator.start_server_side_auction(auction_request, &context) { Ok(pending_auction) => { + ad_slots_script = Some(build_head_globals_script( + &matched_slots, + &request_id, + co_config, + )); server_side_auction = Some(ServerSideAuctionStream::new( request_id, pending_auction, @@ -1193,9 +1224,21 @@ pub fn handle_publisher_request( ad_slots_script: ad_slots_script.as_deref(), }; let mut output = Vec::new(); - process_response_streaming(body, &mut output, ¶ms)?; if let Some(mut auction) = server_side_auction { - auction.complete_with_collected_bids(services, bid_cache); + let mut auction_context = ServerSideAuctionStreamContext { + services, + bid_cache, + server_side_auction: &mut auction, + }; + process_response_streaming_with_ad_auction( + body, + &mut output, + ¶ms, + &mut auction_context, + AuctionFinishMode::CompleteCollected, + )?; + } else { + process_response_streaming(body, &mut output, ¶ms)?; } response.set_header(header::CONTENT_LENGTH, output.len().to_string()); @@ -2580,6 +2623,83 @@ mod creative_opportunities_tests { ); } + #[test] + fn process_response_streaming_with_ad_auction_writes_bids_for_buffered_html() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let services = noop_services(); + let params = ProcessResponseParams { + content_encoding: "", + origin_host: "origin.example.com", + origin_url: "https://origin.example.com", + request_host: "proxy.example.com", + request_scheme: "https", + settings: &settings, + content_type: "text/html; charset=utf-8", + integration_registry: ®istry, + ad_slots_script: None, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-buffered", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: vec![AuctionResponse::success( + "prebid", + vec![make_bid("atf_sidebar", Some(1.239), Some("ad-123"))], + 8, + )], + mediator_response: None, + winning_bids: HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]), + total_time_ms: 8, + metadata: HashMap::new(), + }; + let pending_auction = + PendingAuction::from_completed_result_for_test(make_auction_request(), auction_result); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-buffered".to_string(), + pending_auction, + PriceGranularity::Dense, + false, + settings.proxy.certificate_check, + ); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + + let mut output = Vec::new(); + process_response_streaming_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &mut auction_context, + AuctionFinishMode::CompleteCollected, + ) + .expect("should process buffered HTML and poll auction"); + + match cache + .try_get("rid-buffered") + .expect("should read bid cache") + { + CacheResult::Complete { bids } => { + assert!( + bids.contains_key("atf_sidebar"), + "buffered processing should drain the auction into BidCache" + ); + } + other => panic!("should complete bid cache, got {other:?}"), + } + } + #[test] fn build_server_side_auction_request_uses_matched_slots_and_request_context() { let settings = create_test_settings(); From 5d474dcc3e87a2990a71c27d09c55ee7adfb94ac Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 4 May 2026 13:41:26 +0530 Subject: [PATCH 22/24] Update doc status --- .../2026-04-30-server-side-ad-templates.md | 114 +++++++++--------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md index a24e6f5f8..76ad0566c 100644 --- a/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md +++ b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md @@ -95,7 +95,7 @@ Acceptable proof outcomes: 2. A verified Fastly Core Cache rendezvous plus non-blocking `PendingRequest::poll` design exists that can advance auction pending requests between streaming chunks without delaying HTML chunks. 3. No primitive exists. In that case, stop implementation and update the report with the blocker. Do not replace this with a design that waits for auction completion before streaming. -- [ ] **Step 1: Inspect the runtime primitives** +- [x] **Step 1: Inspect the runtime primitives** Read Fastly and local platform code for `send_async`, `PendingRequest::poll`, `PendingRequest::wait`, `select`, `stream_to_client`, Fastly Core Cache transactions/replacements, and any background execution API. @@ -109,7 +109,7 @@ Acceptable proof outcomes: Expected: enough evidence to identify whether background completion or non-blocking polling is feasible. -- [ ] **Step 2: Write the feasibility report** +- [x] **Step 2: Write the feasibility report** Create `docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md` with: @@ -140,13 +140,13 @@ Acceptable proof outcomes: [Go/Blocked] ``` -- [ ] **Step 3: Verify the response-streaming and rendezvous invariant** +- [x] **Step 3: Verify the response-streaming and rendezvous invariant** Build a small spike or route-level test that proves a matched page can emit its first HTML bytes before a deliberately delayed auction result completes, and that `/ts-bids` can observe the same request ID moving from pending to complete through the selected Fastly rendezvous. Expected: evidence shows first page bytes are emitted before the delayed auction finishes, and `/ts-bids?rid=` sees the completed bid map without relying on process-global memory. -- [ ] **Step 4: Commit the report** +- [x] **Step 4: Commit the report** ```bash git add docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md @@ -162,7 +162,7 @@ Acceptable proof outcomes: - Modify: `Cargo.toml` - Modify: `crates/trusted-server-core/Cargo.toml` -- [ ] **Step 1: Write a temporary failing compile check** +- [x] **Step 1: Write a temporary failing compile check** Temporarily add this to `crates/trusted-server-core/src/lib.rs`: @@ -178,7 +178,7 @@ Acceptable proof outcomes: Expected: compile error because `glob` is not declared yet. -- [ ] **Step 2: Add the workspace dependency** +- [x] **Step 2: Add the workspace dependency** In root `Cargo.toml` under `[workspace.dependencies]`, add: @@ -186,7 +186,7 @@ Acceptable proof outcomes: glob = "0.3" ``` -- [ ] **Step 3: Add the core crate dependency** +- [x] **Step 3: Add the core crate dependency** In `crates/trusted-server-core/Cargo.toml` under `[dependencies]`, add: @@ -194,7 +194,7 @@ Acceptable proof outcomes: glob = { workspace = true } ``` -- [ ] **Step 4: Remove the temporary import and verify** +- [x] **Step 4: Remove the temporary import and verify** Remove the temporary `use glob::Pattern as _;`. @@ -206,7 +206,7 @@ Acceptable proof outcomes: Expected: clean compile. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add Cargo.toml crates/trusted-server-core/Cargo.toml @@ -224,7 +224,7 @@ Acceptable proof outcomes: Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dense`, and `auto` (`auto` routes to `dense`). The April 15 design mentions `custom`, but no custom bucket schema is specified; do not implement `custom` in this task. -- [ ] **Step 1: Write failing tests** +- [x] **Step 1: Write failing tests** Create `crates/trusted-server-core/src/price_bucket.rs` with: @@ -282,7 +282,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: compile failure because the module implementation and export are missing. -- [ ] **Step 2: Implement `price_bucket.rs`** +- [x] **Step 2: Implement `price_bucket.rs`** ```rust //! Prebid price granularity bucketing. @@ -342,7 +342,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens } ``` -- [ ] **Step 3: Export the module** +- [x] **Step 3: Export the module** In `crates/trusted-server-core/src/lib.rs`, add: @@ -350,7 +350,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens pub mod price_bucket; ``` -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** ```bash cargo test -p trusted-server-core price_bucket @@ -358,7 +358,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: all price bucket tests pass. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add crates/trusted-server-core/src/price_bucket.rs crates/trusted-server-core/src/lib.rs @@ -374,7 +374,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens - Modify: `crates/trusted-server-core/src/auction/types.rs` - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` -- [ ] **Step 1: Write failing tests** +- [x] **Step 1: Write failing tests** In the `#[cfg(test)]` module in `crates/trusted-server-core/src/auction/types.rs`, add: @@ -413,7 +413,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: compile failure for missing `MediaType::banner` and `Bid::ad_id`. -- [ ] **Step 2: Add `MediaType::banner()`** +- [x] **Step 2: Add `MediaType::banner()`** Add: @@ -426,7 +426,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens } ``` -- [ ] **Step 3: Add `Bid::ad_id`** +- [x] **Step 3: Add `Bid::ad_id`** Add this field immediately before `metadata`: @@ -437,7 +437,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Update every `Bid` literal in tests and production code with `ad_id: None` unless parsing a real provider ad ID. -- [ ] **Step 4: Populate Prebid ad IDs** +- [x] **Step 4: Populate Prebid ad IDs** In `PrebidAuctionProvider::parse_bid`, add: @@ -451,7 +451,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Include `ad_id` in the returned `AuctionBid`. -- [ ] **Step 5: Run tests** +- [x] **Step 5: Run tests** ```bash cargo test -p trusted-server-core auction::types integrations::prebid @@ -459,7 +459,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: tests pass. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/trusted-server-core/src/auction/types.rs crates/trusted-server-core/src/integrations/prebid.rs @@ -475,7 +475,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens - Create: `crates/trusted-server-core/src/creative_opportunities.rs` - Modify: `crates/trusted-server-core/src/lib.rs` -- [ ] **Step 1: Write failing tests** +- [x] **Step 1: Write failing tests** Create `crates/trusted-server-core/src/creative_opportunities.rs` with tests for: - `/20**` matches multi-segment article paths. @@ -515,7 +515,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: compile failure because implementation/export is missing. -- [ ] **Step 2: Implement config types and matching** +- [x] **Step 2: Implement config types and matching** Implement: @@ -578,7 +578,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Add methods for `matches_path`, `resolved_gam_unit_path`, `resolved_div_id`, `to_ad_slot`, `validate_slot_id`, and `match_slots`. -- [ ] **Step 3: Export the module** +- [x] **Step 3: Export the module** In `crates/trusted-server-core/src/lib.rs`, add: @@ -586,7 +586,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens pub mod creative_opportunities; ``` -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** ```bash cargo test -p trusted-server-core creative_opportunities @@ -594,7 +594,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: tests pass. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add crates/trusted-server-core/src/creative_opportunities.rs crates/trusted-server-core/src/lib.rs @@ -613,7 +613,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens - Create: `creative-opportunities.toml` - Modify: `trusted-server.toml` -- [ ] **Step 1: Write failing settings test** +- [x] **Step 1: Write failing settings test** In `settings.rs` tests, add: @@ -650,7 +650,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: compile failure for missing `Settings::creative_opportunities`. -- [ ] **Step 2: Add settings field** +- [x] **Step 2: Add settings field** Import `CreativeOpportunitiesConfig` and add: @@ -659,7 +659,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens pub creative_opportunities: Option, ``` -- [ ] **Step 3: Add root config files** +- [x] **Step 3: Add root config files** Create `creative-opportunities.toml`: @@ -692,7 +692,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens price_granularity = "dense" ``` -- [ ] **Step 4: Add build-time validation** +- [x] **Step 4: Add build-time validation** In `crates/trusted-server-core/build.rs`, add `cargo:rerun-if-changed` for `../../creative-opportunities.toml`, parse it as `toml::Value`, and validate each `[[slot]].id` with `^[A-Za-z0-9_\-]+$`. @@ -702,7 +702,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens - Empty file with zero slots: valid kill-switch. - Invalid slot ID: startup/build error. -- [ ] **Step 5: Add adapter rebuild trigger** +- [x] **Step 5: Add adapter rebuild trigger** Create `crates/trusted-server-adapter-fastly/build.rs`: @@ -712,7 +712,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens } ``` -- [ ] **Step 6: Run verification** +- [x] **Step 6: Run verification** ```bash cargo test -p trusted-server-core settings_parses_creative_opportunities_section @@ -722,7 +722,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Expected: all pass/build. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add crates/trusted-server-core/src/settings.rs crates/trusted-server-core/build.rs \ @@ -743,7 +743,7 @@ Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dens Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID rendezvous used by the Fastly adapter; do not introduce a broader `AuctionIntent` abstraction in this plan. Production Fastly must not rely on process-global memory. Use Fastly Core Cache for pending/completed bid state, with an in-memory implementation only for unit tests and non-Fastly unsupported paths. -- [ ] **Step 1: Write failing tests** +- [x] **Step 1: Write failing tests** Create tests covering: - unknown request ID -> `CacheResult::NotFound` @@ -763,7 +763,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: compile failure because module is missing. -- [ ] **Step 2: Implement core bid cache types and test implementation** +- [x] **Step 2: Implement core bid cache types and test implementation** Implement: @@ -818,7 +818,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID `AuctionDeadline` must be computed once when the page request starts from both `Instant::now()` and `SystemTime::now()`. In-process tests can use the `Instant`; Fastly Core Cache must persist `epoch_ms` and reconstruct an equivalent local `Instant` on `/ts-bids` so it can enforce the original deadline in a separate request. `wait_for` must not mint a new timeout. -- [ ] **Step 3: Implement Fastly Core Cache `BidCache`** +- [x] **Step 3: Implement Fastly Core Cache `BidCache`** In `crates/trusted-server-adapter-fastly/src/bid_cache.rs`, implement `FastlyBidCache`: - Cache key: `ts-bids:`. @@ -829,7 +829,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Use `fastly::cache::core` APIs, not `static` process memory, for production Fastly rendezvous. -- [ ] **Step 4: Export the module** +- [x] **Step 4: Export the module** In `crates/trusted-server-core/src/lib.rs`, add: @@ -837,7 +837,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID pub mod bid_cache; ``` -- [ ] **Step 5: Run tests** +- [x] **Step 5: Run tests** ```bash cargo test -p trusted-server-core bid_cache @@ -846,7 +846,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: tests pass. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/trusted-server-core/src/bid_cache.rs crates/trusted-server-core/src/lib.rs \ @@ -863,7 +863,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID - Modify: `crates/trusted-server-core/src/html_processor.rs` - Modify: `crates/trusted-server-core/src/publisher.rs` -- [ ] **Step 1: Write failing HTML processor tests** +- [x] **Step 1: Write failing HTML processor tests** Add tests proving: - `ad_slots_script` is prepended at `` open. @@ -879,7 +879,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: compile failure for missing `HtmlProcessorConfig::ad_slots_script`. -- [ ] **Step 2: Add config field** +- [x] **Step 2: Add config field** Add to `HtmlProcessorConfig`: @@ -890,7 +890,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Initialize it to `None` in `HtmlProcessorConfig::from_settings`. -- [ ] **Step 3: Inject once at `` open** +- [x] **Step 3: Inject once at `` open** In the existing `element!("head", ...)` handler: - Build one `snippet` string. @@ -901,7 +901,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Do not add `on_end_tag`. -- [ ] **Step 4: Add publisher script helpers** +- [x] **Step 4: Add publisher script helpers** In `publisher.rs`, add `pub(crate)` helpers: - `build_head_globals_script(matched_slots, request_id, co_config) -> String` @@ -909,7 +909,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID The script must use `JSON.parse("...escaped JSON...")` and must not interpolate raw JSON into executable JavaScript. -- [ ] **Step 5: Run tests** +- [x] **Step 5: Run tests** ```bash cargo test -p trusted-server-core html_processor @@ -917,7 +917,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: tests pass. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/trusted-server-core/src/html_processor.rs crates/trusted-server-core/src/publisher.rs @@ -932,7 +932,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID - Modify: `crates/trusted-server-core/src/publisher.rs` -- [ ] **Step 1: Write failing helper tests** +- [x] **Step 1: Write failing helper tests** Add tests covering: - `build_head_globals_script` includes slots and request ID. @@ -953,7 +953,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: failure because helpers are missing. -- [ ] **Step 2: Implement bid map helper** +- [x] **Step 2: Implement bid map helper** Implement `build_bid_map`: @@ -980,7 +980,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID } ``` -- [ ] **Step 3: Implement consent helper** +- [x] **Step 3: Implement consent helper** Implement a small helper used only by the server-side ad-template path: @@ -995,7 +995,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID This intentionally follows the April 15 design: absent TCF means no server-side auction and no ad globals for this Phase 1 path. -- [ ] **Step 4: Implement browser cache policy helper** +- [x] **Step 4: Implement browser cache policy helper** Implement a helper that receives `slots_matched: bool` and `globals_injected: bool`. @@ -1005,7 +1005,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID - If globals are injected, set `Cache-Control: private, no-store`. - Preserve `Surrogate-Control` and `Fastly-Surrogate-Control`. -- [ ] **Step 5: Run tests** +- [x] **Step 5: Run tests** ```bash cargo test -p trusted-server-core publisher::creative_opportunities_tests @@ -1013,7 +1013,7 @@ Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID Expected: tests pass. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/trusted-server-core/src/publisher.rs @@ -1389,7 +1389,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap - Modify only if needed by test fixes. -- [ ] **Step 1: Run Rust tests** +- [x] **Step 1: Run Rust tests** ```bash cargo test --workspace @@ -1397,7 +1397,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap Expected: all tests pass. -- [ ] **Step 2: Run Rust formatting check** +- [x] **Step 2: Run Rust formatting check** ```bash cargo fmt --all -- --check @@ -1405,7 +1405,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap Expected: no formatting changes needed. -- [ ] **Step 3: Run Clippy** +- [x] **Step 3: Run Clippy** ```bash cargo clippy --workspace --all-targets --all-features -- -D warnings @@ -1413,7 +1413,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap Expected: no warnings. -- [ ] **Step 4: Run JS tests and build** +- [x] **Step 4: Run JS tests and build** ```bash cd crates/js/lib && npx vitest run @@ -1422,7 +1422,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap Expected: tests and build pass. -- [ ] **Step 5: Run manual Fastly verification** +- [x] **Step 5: Run manual Fastly verification** Run: @@ -1455,7 +1455,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap - `/ts-bids?rid=not-real` returns `404`. - First HTML bytes arrive before a delayed auction completes, using the evidence path from Task 1. -- [ ] **Step 6: Run browser verification** +- [x] **Step 6: Run browser verification** With `fastly compute serve` still running, verify manually in Chrome or with Chrome MCP: - Open `http://127.0.0.1:7676/2024/01/test-article/`. @@ -1465,7 +1465,7 @@ This task implements `nurl` firing only for the Fastly Phase 1 path. Future adap - Confirm the console has no GPT bootstrap errors. - Confirm no ad globals exist on `/about` or matched pages with denied/missing consent. -- [ ] **Step 7: Commit any final test/documentation fixes** +- [x] **Step 7: Commit any final test/documentation fixes** ```bash git status --short From 24350b14ab81c43b029d566441759c15edffe644 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 4 May 2026 15:09:48 +0530 Subject: [PATCH 23/24] Remove no-op globals_injected param, drop thread::sleep from WASM drain path, bound rid to 256 bytes, validate page_pattern globs at build time, switch InMemoryBidCache eviction to VecDeque, tighten FastlyBidCache max_reconstructed_wait default to 800 ms --- .../src/bid_cache.rs | 6 +++- .../trusted-server-adapter-fastly/src/main.rs | 4 ++- crates/trusted-server-core/Cargo.toml | 1 + crates/trusted-server-core/build.rs | 9 ++++++ crates/trusted-server-core/src/bid_cache.rs | 9 +++--- .../src/integrations/gpt.rs | 3 ++ crates/trusted-server-core/src/publisher.rs | 30 ++++++------------- 7 files changed, 34 insertions(+), 28 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/bid_cache.rs b/crates/trusted-server-adapter-fastly/src/bid_cache.rs index 92e23625a..aa9e4cbdc 100644 --- a/crates/trusted-server-adapter-fastly/src/bid_cache.rs +++ b/crates/trusted-server-adapter-fastly/src/bid_cache.rs @@ -9,6 +9,10 @@ use trusted_server_core::bid_cache::{ }; const DEFAULT_BID_CACHE_TTL: Duration = Duration::from_secs(30); +// Conservative upper bound for reconstructed deadlines; keeps /ts-bids from +// long-polling up to DEFAULT_BID_CACHE_TTL (30 s) when the auction already +// finished. Auction timeouts are typically 200–500 ms. +const DEFAULT_MAX_RECONSTRUCTED_WAIT: Duration = Duration::from_millis(800); /// Fastly Core Cache-backed [`BidCache`] implementation. pub struct FastlyBidCache { @@ -21,7 +25,7 @@ impl FastlyBidCache { pub fn new() -> Self { Self { ttl: DEFAULT_BID_CACHE_TTL, - max_reconstructed_wait: DEFAULT_BID_CACHE_TTL, + max_reconstructed_wait: DEFAULT_MAX_RECONSTRUCTED_WAIT, } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 963634cf8..46a90afb7 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -332,12 +332,14 @@ async fn route_request( Some(response) } +const MAX_REQUEST_ID_BYTES: usize = 256; + fn handle_ts_bids_request(req: &Request, bid_cache: &impl BidCache) -> Response { let Some(request_id) = req .get_url() .query_pairs() .find_map(|(key, value)| (key == "rid").then_some(value.into_owned())) - .filter(|value| !value.is_empty()) + .filter(|value| !value.is_empty() && value.len() <= MAX_REQUEST_ID_BYTES) else { return text_ts_bids_response(StatusCode::BAD_REQUEST, "missing rid\n"); }; diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 909763ce4..569ecdd83 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -55,6 +55,7 @@ edgezero-core = { workspace = true } config = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } +glob = { workspace = true } http = { workspace = true } log = { workspace = true } regex = { workspace = true } diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 3261db670..d8b14a206 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -165,5 +165,14 @@ fn validate_creative_opportunities_config() { "{config_path:?}: invalid slot id `{id}`; use only ASCII letters, digits, `_`, and `-`" ); } + + for pattern in &typed_slot.page_patterns { + let normalized = pattern.replace("**", "*"); + if glob::Pattern::new(pattern).is_err() && glob::Pattern::new(&normalized).is_err() { + panic!( + "{config_path:?}: slot `{id}` has invalid page_pattern `{pattern}`" + ); + } + } } } diff --git a/crates/trusted-server-core/src/bid_cache.rs b/crates/trusted-server-core/src/bid_cache.rs index 6ad25d371..1b23a0dcb 100644 --- a/crates/trusted-server-core/src/bid_cache.rs +++ b/crates/trusted-server-core/src/bid_cache.rs @@ -2,7 +2,7 @@ use error_stack::Report; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -195,7 +195,7 @@ pub struct InMemoryBidCache { #[derive(Default)] struct BidCacheInner { entries: HashMap, - insertion_order: Vec, + insertion_order: VecDeque, } #[derive(Debug, Clone)] @@ -306,7 +306,7 @@ impl InMemoryBidCache { }, ); inner.insertion_order.retain(|key| key != request_id); - inner.insertion_order.push(request_id.to_string()); + inner.insertion_order.push_back(request_id.to_string()); self.enforce_capacity(&mut inner); Ok(()) @@ -329,9 +329,8 @@ impl InMemoryBidCache { } while inner.entries.len() > self.capacity { - if let Some(oldest_key) = inner.insertion_order.first().cloned() { + if let Some(oldest_key) = inner.insertion_order.pop_front() { inner.entries.remove(&oldest_key); - inner.insertion_order.remove(0); } else { break; } diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index ac827c663..b372e0c20 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -64,6 +64,9 @@ const ROUTE_PREFIX: &str = "/integrations/gpt"; const GPT_SHIM_INSTALL_SNIPPET: &str = ""; +// IMPORTANT: This is the compiled/minified equivalent of installTsAdInit() in +// crates/js/lib/src/integrations/gpt/index.ts. Any logic change MUST be made +// in both places; rebuild the TypeScript bundle before updating this constant. const TS_AD_INIT_SNIPPET: &str = r#""#; /// Configuration for the Google Publisher Tags integration. diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 540e1c9ca..463f290e2 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -186,7 +186,6 @@ fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { } /// Build the head script that exposes server-side ad slot metadata. -#[allow(dead_code)] #[must_use] pub(crate) fn build_head_globals_script( matched_slots: &[&CreativeOpportunitySlot], @@ -228,7 +227,6 @@ pub(crate) fn build_head_globals_script( } /// Escape JSON so it can be embedded in a JavaScript string inside an HTML script. -#[allow(dead_code)] #[must_use] pub(crate) fn html_escape_for_script(json: &str) -> String { let mut escaped = String::with_capacity(json.len()); @@ -247,7 +245,6 @@ pub(crate) fn html_escape_for_script(json: &str) -> String { escaped } -#[allow(dead_code)] #[must_use] pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, @@ -270,7 +267,6 @@ pub(crate) fn build_bid_map( .collect() } -#[allow(dead_code)] fn server_side_auction_allowed(consent_context: &crate::consent::ConsentContext) -> bool { consent_context .tcf @@ -278,17 +274,8 @@ fn server_side_auction_allowed(consent_context: &crate::consent::ConsentContext) .is_some_and(|tcf| tcf.has_purpose_consent(1)) } -#[allow(dead_code)] -fn apply_server_side_ad_cache_policy( - response: &mut Response, - slots_matched: bool, - globals_injected: bool, -) { - if !slots_matched { - return; - } - - if globals_injected || slots_matched { +fn apply_server_side_ad_cache_policy(response: &mut Response, slots_matched: bool) { + if slots_matched { response.set_header(header::CACHE_CONTROL, "private, no-store"); } } @@ -713,7 +700,8 @@ impl ServerSideAuctionStream { while !self.completed { match self.pending_auction.poll_once_now(services) { Ok(PendingAuctionPoll::Pending) => { - std::thread::sleep(std::time::Duration::from_millis(1)); + // poll_once_now already checks the deadline internally via finish_poll_round; + // no sleep — wasm32-wasip1 has no scheduler to yield to. } Ok(PendingAuctionPoll::Complete(result)) => { self.write_result(services, bid_cache, &result); @@ -1116,7 +1104,7 @@ pub fn handle_publisher_request( existing_ec_cookie.as_deref(), &consent_context, ); - apply_server_side_ad_cache_policy(&mut response, slots_matched, ad_slots_script.is_some()); + apply_server_side_ad_cache_policy(&mut response, slots_matched); let content_type = response .get_header(header::CONTENT_TYPE) @@ -1628,7 +1616,7 @@ mod creative_opportunities_tests { let mut response = Response::from_status(StatusCode::OK); response.set_header(header::CACHE_CONTROL, "public, max-age=300"); - apply_server_side_ad_cache_policy(&mut response, true, false); + apply_server_side_ad_cache_policy(&mut response, true); assert_eq!( response.get_header_str(header::CACHE_CONTROL), @@ -1642,7 +1630,7 @@ mod creative_opportunities_tests { let mut response = Response::from_status(StatusCode::OK); response.set_header(header::CACHE_CONTROL, "public, max-age=300"); - apply_server_side_ad_cache_policy(&mut response, true, true); + apply_server_side_ad_cache_policy(&mut response, true); assert_eq!( response.get_header_str(header::CACHE_CONTROL), @@ -1658,7 +1646,7 @@ mod creative_opportunities_tests { response.set_header("Surrogate-Control", "max-age=3600"); response.set_header("Fastly-Surrogate-Control", "max-age=7200"); - apply_server_side_ad_cache_policy(&mut response, true, true); + apply_server_side_ad_cache_policy(&mut response, true); assert_eq!( response.get_header_str("Surrogate-Control"), @@ -1679,7 +1667,7 @@ mod creative_opportunities_tests { response.set_header("Surrogate-Control", "max-age=3600"); response.set_header("Fastly-Surrogate-Control", "max-age=7200"); - apply_server_side_ad_cache_policy(&mut response, false, false); + apply_server_side_ad_cache_policy(&mut response, false); assert_eq!( response.get_header_str(header::CACHE_CONTROL), From e501d16f55b0ac3fa37b59a0353134db848c76e6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 4 May 2026 21:15:19 +0530 Subject: [PATCH 24/24] Fix fmt in build.rs, expand creative-opportunities example page patterns - Collapse single-argument panic! in validate_creative_opportunities_config to satisfy rustfmt line-length rules - Expand example page_patterns to demonstrate multiple pattern syntaxes (/20**, /news/*, /, /about) and lower floor to show floor-based filtering --- crates/trusted-server-core/build.rs | 4 +--- creative-opportunities.toml | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index d8b14a206..a421331a8 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -169,9 +169,7 @@ fn validate_creative_opportunities_config() { for pattern in &typed_slot.page_patterns { let normalized = pattern.replace("**", "*"); if glob::Pattern::new(pattern).is_err() && glob::Pattern::new(&normalized).is_err() { - panic!( - "{config_path:?}: slot `{id}` has invalid page_pattern `{pattern}`" - ); + panic!("{config_path:?}: slot `{id}` has invalid page_pattern `{pattern}`"); } } } diff --git a/creative-opportunities.toml b/creative-opportunities.toml index 243aa515c..981ff8d9c 100644 --- a/creative-opportunities.toml +++ b/creative-opportunities.toml @@ -5,9 +5,9 @@ id = "atf_sidebar_ad" gam_unit_path = "/21765378893/publisher/atf-sidebar" div_id = "div-atf-sidebar" -page_patterns = ["/20**"] +page_patterns = ["/20**", "/about", "/news/*", "/"] formats = [{ width = 300, height = 250 }] -floor_price = 0.50 +floor_price = 0.10 [slot.targeting] pos = "atf"