From ce4157e67f7de6872841e5cf2cac70b3f343ee99 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 17 May 2026 22:38:42 -0700 Subject: [PATCH 1/9] Tidy small review nits across the new auction surface Addresses review findings on #680: - P2-18 / price_bucket: reject NaN/Inf cpm up front before the (x * 100.0).floor() as u64 cast (Rust's NaN-cast behaviour is only safe by convention, not contract). Add test coverage. - P2-24 / publisher.rs::write_bids_to_state: drop the per-request log line from INFO to DEBUG so production logs don't spam a slot list on every page request. - P2-25 / publisher.rs::build_bids_script / build_ad_slots_script: serde_json::to_string of a Map / Vec is infallible -- use expect("should be infallible") instead of unwrap_or_else with a silent fallback that would mask any future bug. - P2-15 / prebid: drop the dead PrebidIntegrationConfig::suppress_nurl field -- declared but never read anywhere in the codebase. The no-op test it carried goes with it. --- .../src/integrations/prebid.rs | 11 ------- .../trusted-server-core/src/price_bucket.rs | 32 ++++++++++++++++++- crates/trusted-server-core/src/publisher.rs | 8 +++-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d7711d61..b74b234c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -164,10 +164,6 @@ pub struct PrebidIntegrationConfig { /// - `both` — consent in both cookies and body (default) #[serde(default)] pub consent_forwarding: ConsentForwardingMode, - /// When true, suppresses client-side nurl firing. - /// Use for PBS deployments that fire nurl internally. - #[serde(default)] - pub suppress_nurl: bool, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -1661,16 +1657,9 @@ mod tests { bid_param_overrides: HashMap::default(), bid_param_override_rules: Vec::new(), consent_forwarding: ConsentForwardingMode::Both, - suppress_nurl: false, } } - #[test] - fn prebid_config_suppress_nurl_defaults_to_false() { - let config = base_config(); - assert!(!config.suppress_nurl, "should not suppress nurl by default"); - } - fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), diff --git a/crates/trusted-server-core/src/price_bucket.rs b/crates/trusted-server-core/src/price_bucket.rs index b683020b..cfdca9eb 100644 --- a/crates/trusted-server-core/src/price_bucket.rs +++ b/crates/trusted-server-core/src/price_bucket.rs @@ -20,7 +20,10 @@ impl PriceGranularity { #[must_use] pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { - if cpm <= 0.0 { + // Reject NaN / Inf early so the `(x * 100.0).floor() as u64` cast below + // can never see a non-finite value (the cast's behaviour for NaN/Inf is + // implementation-defined in Rust and "saturate to 0" only by convention). + if !cpm.is_finite() || cpm <= 0.0 { return "0.00".to_string(); } match granularity { @@ -125,4 +128,31 @@ mod tests { price_bucket(2.53, PriceGranularity::Dense) ); } + + #[test] + fn non_finite_cpm_returns_zero_bucket() { + for granularity in [ + PriceGranularity::Dense, + PriceGranularity::Low, + PriceGranularity::Medium, + PriceGranularity::High, + PriceGranularity::Auto, + ] { + assert_eq!( + price_bucket(f64::NAN, granularity), + "0.00", + "NaN cpm should bucket to 0.00 for granularity {granularity:?}" + ); + assert_eq!( + price_bucket(f64::INFINITY, granularity), + "0.00", + "+Inf cpm should bucket to 0.00 for granularity {granularity:?}" + ); + assert_eq!( + price_bucket(f64::NEG_INFINITY, granularity), + "0.00", + "-Inf cpm should bucket to 0.00 for granularity {granularity:?}" + ); + } + } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 2974eccd..ea7eaed5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -566,7 +566,7 @@ pub(crate) fn write_bids_to_state( price_granularity: PriceGranularity, ad_bids_state: &Arc>>, ) { - log::info!( + log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") @@ -1288,7 +1288,8 @@ pub(crate) fn build_bid_map( /// The JSON is embedded via `JSON.parse(…)` so the browser parser never sees /// raw `` sequences inside the string. pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { - let json = serde_json::to_string(bid_map).unwrap_or_else(|_| "{}".to_string()); + let json = serde_json::to_string(bid_map) + .expect("serde_json::to_string of Map should be infallible"); let escaped = html_escape_for_script(&json); format!( "", @@ -1328,7 +1329,8 @@ pub(crate) fn build_ad_slots_script( }) }) .collect(); - let json = serde_json::to_string(&slots).unwrap_or_else(|_| "[]".to_string()); + let json = serde_json::to_string(&slots) + .expect("serde_json::to_string of Vec should be infallible"); let escaped = html_escape_for_script(&json); format!( "", From 94d9efee5ea6b0199440ac8a18a142b841bb682d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 17 May 2026 22:40:34 -0700 Subject: [PATCH 2/9] Extract GPT bootstrap script and guard double enableServices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on #680: - P2-2: the inline `__tsAdInit` bootstrap injected at called googletag.pubads().enableSingleRequest() and googletag.enableServices() unconditionally. The TS bundle's later-installed version guards both with a `__tsServicesEnabled` flag — the inline version did not, so the publisher's own GPT init code (or an upgrade where the bundle loads before the bids script runs) caused double-enable + duplicate refresh(), producing duplicate ad requests on every load. Now both inline and bundle converge on the same flag and only invoke `refresh(newSlots)` for the slots this pass actually defined, never the global slot list. - P2-26: the bootstrap source moves out of a concat!() literal block in head_inserts() into a syntax-highlighted gpt_bootstrap.js file pulled in via include_str!. The Rust side keeps a single named constant, GPT_BOOTSTRAP_JS, so future edits diff cleanly. Adds a regression test that asserts the guard flag is present and that unbounded refresh() is gone. --- .../src/integrations/gpt.rs | 71 +++++++++-------- .../src/integrations/gpt_bootstrap.js | 78 +++++++++++++++++++ 2 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 crates/trusted-server-core/src/integrations/gpt_bootstrap.js diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 85ea800e..cb099402 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -455,44 +455,21 @@ impl IntegrationHeadInjector for GptIntegration { "" .to_string(), - concat!( - "" - ).to_string(), + format!("", GPT_BOOTSTRAP_JS), ] } } +/// Inline `window.__tsAdInit` bootstrap injected at `` so the bids +/// script at `` can call it before the TSJS bundle has loaded. +/// +/// The bundle's idempotent implementation in +/// `crates/js/lib/src/integrations/gpt/index.ts` later overwrites this stub. +/// Both implementations guard the one-time-per-page setup with +/// `window.__tsServicesEnabled` so neither double-enables services if the +/// publisher's own init code also calls `googletag.enableServices()`. +const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); + // Default value functions fn default_enabled() -> bool { @@ -1120,6 +1097,32 @@ mod tests { ); } + #[test] + fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() { + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("__tsServicesEnabled"), + "should guard enableServices/enableSingleRequest with the __tsServicesEnabled flag" + ); + assert!( + combined.contains("window.__tsAdInit"), + "should install __tsAdInit on window" + ); + assert!( + !combined.contains("googletag.pubads().refresh()"), + "should never call unbounded refresh() — only refresh(newSlots)" + ); + } + #[test] fn head_injector_integration_id() { let integration = GptIntegration::new(test_config()); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js new file mode 100644 index 00000000..a3d28a28 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -0,0 +1,78 @@ +// Edge-injected GPT auction bootstrap. +// +// This is the minimal `window.__tsAdInit` that runs on first page load +// before the TSJS bundle has had a chance to install its richer +// idempotent implementation. The bundle in +// crates/js/lib/src/integrations/gpt/index.ts overwrites `__tsAdInit` +// once it loads. +// +// Contract with the bundle: +// - Both implementations must set `window.__tsServicesEnabled = true` +// after calling `enableSingleRequest()`/`enableServices()` so a +// subsequent call from any source (the bundle's `__tsAdInit`, the +// publisher's own GPT init code) becomes a no-op. +// - `refresh()` is called only for the slots defined in this pass, +// never the global slot list, so we never accidentally refresh +// publisher-managed slots that we don't own. +// +// Only installed if `window.__tsAdInit` isn't already defined — that +// way the bundle (or anything else) can preempt this fallback by +// installing first. +(function () { + if (typeof window === "undefined" || window.__tsAdInit) { + return; + } + window.__tsAdInit = function () { + var slots = window.__ts_ad_slots || []; + var bids = window.__ts_bids || {}; + var divToSlotId = {}; + googletag.cmd.push(function () { + var newSlots = []; + slots.forEach(function (slot) { + var s = googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + slot.div_id, + ); + if (!s) return; + s.addService(googletag.pubads()); + Object.entries(slot.targeting || {}).forEach(function (e) { + s.setTargeting(e[0], e[1]); + }); + var b = bids[slot.id] || {}; + ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]); + }); + s.setTargeting("ts_initial", "1"); + divToSlotId[slot.div_id] = slot.id; + newSlots.push(s); + }); + // Guard the one-time-per-page setup so a follow-up call (e.g. + // publisher's own init code or the bundle's `__tsAdInit` after + // it overwrites this stub) doesn't double-enable services. + if (!window.__tsServicesEnabled) { + googletag.pubads().enableSingleRequest(); + googletag.enableServices(); + window.__tsServicesEnabled = true; + googletag + .pubads() + .addEventListener("slotRenderEnded", function (ev) { + var divId = ev.slot.getSlotElementId(); + var slotId = divToSlotId[divId] || divId; + var b = (window.__ts_bids || {})[slotId] || {}; + var ourBidWon = + !ev.isEmpty && + b.hb_adid && + ev.slot.getTargeting("hb_adid")[0] === b.hb_adid; + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl); + if (b.burl) navigator.sendBeacon(b.burl); + } + }); + } + if (newSlots.length > 0) { + googletag.pubads().refresh(newSlots); + } + }); + }; +})(); From 9e0e22345ed45413f2c2a3b6493f31e50e04b15c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 17 May 2026 22:42:52 -0700 Subject: [PATCH 3/9] Harden auction-orchestrator state cleanup Addresses review findings on #680: - P2-6: APS provider held its per-request slot_id_map across request boundaries when the same Wasm instance was reused (the mock provider already cleared its bid_index, APS did not). parse_response now `std::mem::take`s the map so it can never carry over to a subsequent request. - P2-7: apply_floor_prices used to silently pass bids with `price=None` through the floor filter. Today both production callers decode/skip None before calling, so the pass-through was dead code that would, if revived, cause winning_bids.len() to overcount what build_bid_map ships to the client. Drop the None branch and update the existing test to pin the new contract: callers must decode prices first. --- .../src/auction/orchestrator.rs | 78 +++++++++---------- .../src/integrations/aps.rs | 14 +++- 2 files changed, 45 insertions(+), 47 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index bf45c47e..8151bbb4 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -540,32 +540,29 @@ impl AuctionOrchestrator { } let starting_count = winning_bids.len(); - winning_bids.retain(|slot_id, bid| match floor_prices.get(slot_id) { - Some(floor) => { - // price=None means the SSP returned an encoded price (e.g. APS amznbid). - // In the parallel-only path this bid cannot yet be floor-checked; it passes - // through and will be decoded (and re-checked) by the mediation layer. - // In the mediation path, mediation decodes prices before calling this - // function, so any bid still carrying price=None is dropped upstream. - match bid.price { - Some(price) if price >= *floor => true, - Some(_) => { - log::info!( - "Dropping winning bid below floor price for slot '{}'", - slot_id - ); - false - } - None => { - log::debug!( - "Passing encoded-price bid for slot '{}' - price not yet decoded", - slot_id - ); - true - } - } + winning_bids.retain(|slot_id, bid| match (floor_prices.get(slot_id), bid.price) { + (Some(floor), Some(price)) if price >= *floor => true, + (Some(_), Some(_)) => { + log::info!( + "Dropping winning bid below floor price for slot '{}'", + slot_id + ); + false } - None => true, + (_, None) => { + // Any caller that needs to keep an undecoded (encoded-price) + // bid must decode it *before* invoking this function — both + // `select_winning_bids` and the mediator path already do. + // Letting `None`-price bids through here would cause + // `winning_bids.len()` to overcount what `build_bid_map` + // downstream is willing to emit, so they get dropped instead. + log::debug!( + "Dropping bid for slot '{}' - no decoded price (caller must decode before apply_floor_prices)", + slot_id + ); + false + } + (None, Some(_)) => true, }); if winning_bids.len() != starting_count { @@ -1256,9 +1253,14 @@ mod tests { } #[test] - fn test_apply_floor_prices_allows_none_prices_for_encoded_bids() { - // Test that bids with None prices (APS-style) pass through floor pricing - // This is correct behavior for parallel-only strategy where mediation happens later + fn test_apply_floor_prices_drops_bids_with_undecoded_price() { + // Bids that reach apply_floor_prices with `price=None` cannot have a + // floor compared against them — and they would not survive downstream + // (build_bid_map filters them) — so apply_floor_prices drops them so + // the count it reports matches what eventually ships to the client. + // Both production paths (select_winning_bids and the mediator filter) + // already decode/skip None prices before calling this function; this + // test pins the contract. let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); let mut floor_prices = HashMap::new(); floor_prices.insert("slot-1".to_string(), 1.00); @@ -1268,7 +1270,7 @@ mod tests { "slot-1".to_string(), Bid { slot_id: "slot-1".to_string(), - price: None, // APS bid with encoded price + price: None, currency: "USD".to_string(), creative: Some("
Ad
".to_string()), adomain: None, @@ -1289,25 +1291,15 @@ mod tests { }, ); - // Apply floor pricing - should pass through with None price let filtered = orchestrator.apply_floor_prices(winning_bids, &floor_prices); - assert_eq!( - filtered.len(), - 1, - "APS bid with None price should pass through floor check" - ); assert!( - filtered.contains_key("slot-1"), - "Slot-1 should still be present" + filtered.is_empty(), + "bid with None price should be dropped by apply_floor_prices" ); assert!( - filtered - .get("slot-1") - .expect("slot-1 should be present") - .price - .is_none(), - "Price should still be None (not decoded yet)" + !filtered.contains_key("slot-1"), + "slot-1 should not survive when its bid has no decoded price" ); } diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 850798e4..71ef2bbe 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -456,10 +456,16 @@ impl ApsAuctionProvider { aps_response.contextual.slots.len() ); - let slot_map = self - .slot_id_map - .lock() - .expect("should lock APS slot id map"); + // Take the map by value so it does not linger on the provider + // across requests if the Fastly Compute runtime ever reuses Wasm + // instances. Today each request gets its own instance so this is + // belt-and-suspenders; tomorrow it may not be. + let slot_map = std::mem::take( + &mut *self + .slot_id_map + .lock() + .expect("should lock APS slot id map"), + ); for slot in aps_response.contextual.slots { match self.parse_aps_slot(&slot) { Ok(mut bid) => { From 876bb0e71069159524cd099033bea18e2a27b998 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 17 May 2026 22:45:59 -0700 Subject: [PATCH 4/9] Harden /__ts/page-bids and creative-opportunities loading Addresses review findings on #680: - P2-4: /__ts/page-bids ran the full SSP auction for every request without gating crawlers or prefetches the way the publisher path does, exposing partner request quota to client-side spraying. Apply the same is_bot / is_prefetch gate handle_publisher_request uses: slots are still returned (so HTML structure is unchanged) but the auction is short-circuited. New regression tests cover both gates. - P2-5 + P2-13: the adapter previously parsed CREATIVE_OPPORTUNITIES_TOML on every request and `expect("should parse...")` on failure, so a malformed embedded TOML (CI-bypassed binary patch, future schema change, anything build.rs didn't catch) would panic every request. Parse it lazily once per Wasm instance via LazyLock; on parse failure log an error and fall back to the documented "empty slots file = feature disabled" state instead of panicking. --- .../trusted-server-adapter-fastly/src/main.rs | 34 +++++- crates/trusted-server-core/src/publisher.rs | 100 +++++++++++++++++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 24c447d3..c236fec2 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -42,6 +42,34 @@ use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore} const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); +/// Parses the embedded `creative-opportunities.toml` at most once per Wasm +/// instance. +/// +/// On parse failure, logs an error and falls back to an empty +/// [`CreativeOpportunitiesFile`] — i.e. the documented "feature disabled" +/// state — instead of panicking the request hot path. The build-time +/// validator in `crates/trusted-server-core/build.rs` catches every realistic +/// authoring mistake; this fallback exists so a CI-bypassed binary patch or a +/// future schema change can't take the entire fleet down with a per-request +/// panic. +static SLOTS_FILE: + std::sync::LazyLock = + std::sync::LazyLock::new(|| { + match toml::from_str::( + CREATIVE_OPPORTUNITIES_TOML, + ) { + Ok(file) => file, + Err(err) => { + log::error!( + "creative-opportunities.toml failed to parse at startup; \ + falling back to an empty slots file (server-side ad-slot \ + templates disabled): {err}" + ); + trusted_server_core::creative_opportunities::CreativeOpportunitiesFile::default() + } + } + }); + /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `Request::from_client()` instead of @@ -94,9 +122,7 @@ fn main() { } }; - let slots_file: trusted_server_core::creative_opportunities::CreativeOpportunitiesFile = - toml::from_str(CREATIVE_OPPORTUNITIES_TOML) - .expect("should parse creative-opportunities.toml"); + let slots_file = &*SLOTS_FILE; let integration_registry = match IntegrationRegistry::new(&settings) { Ok(r) => r, @@ -121,7 +147,7 @@ fn main() { &orchestrator, &integration_registry, &runtime_services, - &slots_file, + slots_file, req, )) { response.send_to_client(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ea7eaed5..d057362e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1465,14 +1465,39 @@ pub async fn handle_page_bids( .as_ref() .is_some_and(|tcf| tcf.has_purpose_consent(1)); + // Same bot / prefetch guards the publisher path uses — without them this + // endpoint would fire real SSP auctions on Sec-Purpose=prefetch warm-up + // navigations and known crawler UA scans, burning partner request quota. + let is_prefetch = req + .get_header_str("sec-purpose") + .is_some_and(|v| v.contains("prefetch")) + || req + .get_header_str("purpose") + .is_some_and(|v| v.contains("prefetch")); + let user_agent = req.get_header_str("user-agent").unwrap_or(""); + let is_bot = ["Googlebot", "Bingbot", "AhrefsBot", "SemrushBot", "DotBot"] + .iter() + .any(|bot| user_agent.contains(bot)); + if matched_slots.is_empty() { log::debug!( "No creative opportunity slots matched path '{}' — skipping auction", path_param ); + } else if is_bot || is_prefetch { + log::debug!( + "page-bids: skipping auction for path '{}' (is_bot={}, is_prefetch={})", + path_param, + is_bot, + is_prefetch + ); } - let winning_bids = if !matched_slots.is_empty() && consent_allows_auction { + let winning_bids = if !matched_slots.is_empty() + && consent_allows_auction + && !is_bot + && !is_prefetch + { let mut auction_request = build_auction_request( &matched_slots, &ec_id, @@ -2773,6 +2798,79 @@ mod tests { ); } + #[tokio::test] + async fn bot_user_agent_returns_slots_but_no_bids() { + // Crawlers should get slot definitions (so HTML structure is unchanged) + // but the server must not burn SSP request quota running a real auction + // for them. Same gate the publisher path applies. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let slots_file = file_with_article_slot(); + let mut req = make_page_bids_request("/2024/01/my-article/"); + req.set_header("user-agent", "Mozilla/5.0 (compatible; Googlebot/2.1)"); + + let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + .await + .expect("should return ok response"); + + let body: serde_json::Value = + serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 1, + "bot request should still get slot definitions" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "bot request must not run an auction (no SSP cost burned for crawlers)" + ); + } + + #[tokio::test] + async fn prefetch_request_returns_slots_but_no_bids() { + // Navigations triggered by Sec-Purpose=prefetch should not fire real + // SSP auctions — the user has not yet visited the page. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let slots_file = file_with_article_slot(); + let mut req = make_page_bids_request("/2024/01/my-article/"); + req.set_header("sec-purpose", "prefetch"); + + let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + .await + .expect("should return ok response"); + + let body: serde_json::Value = + serde_json::from_slice(&response.into_body_bytes()).expect("should be json"); + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 1, + "prefetch request should still get slot definitions" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "prefetch request must not run an auction" + ); + } + #[tokio::test] async fn url_not_matching_any_pattern_returns_empty_response() { // Slots exist but request path does not match — no auction, no injection. From e395215492246fb68281787c4c4ee6fe9e45209d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 17 May 2026 22:51:50 -0700 Subject: [PATCH 5/9] Consolidate HTML stream processor + auction helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on #680: - P2-3 + P2-16: create_html_stream_processor previously built HtmlProcessorConfig inline, bypassing HtmlProcessorConfig::from_settings. A future edit to from_settings (e.g. to read a new flag from Settings) would silently miss the streaming-with-auction-hold path. Now goes through from_settings, with a new with_ad_state(...) builder method that layers the ad_slots_script / ad_bids_state fields on top. The `_settings` argument on create_html_stream_processor is now actually used and is no longer underscore-prefixed. - P2-17: the auction debug-comment prepend logic was duplicated between one_behind_loop (path=stream) and the BufferedProcessed arm (path=buffered). Extracted as `prepend_auction_debug_comment(label, result, state)` so the single source of truth gets one definition and the only difference between paths is the label string. - P2-14: build_auction_request was at the project's 7-argument cap. Bundled (matched_slots, request_path, co_config) into a new MatchedSlotsContext struct — the three fields always travel together anyway. The function now takes 5 args, leaving headroom for future per-request inputs without breaking the project rule. --- .../trusted-server-core/src/html_processor.rs | 18 +++ crates/trusted-server-core/src/publisher.rs | 144 ++++++++++-------- 2 files changed, 98 insertions(+), 64 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 26978cef..b60f6f93 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -167,6 +167,24 @@ impl HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), } } + + /// Attach the streaming-auction `