diff --git a/Cargo.lock b/Cargo.lock index e06ac75e7..65d1d777c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1151,6 +1151,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" @@ -2707,6 +2713,7 @@ dependencies = [ "log-fastly", "serde", "serde_json", + "toml 1.0.7+spec-1.1.0", "trusted-server-core", "urlencoding", ] @@ -2731,6 +2738,7 @@ dependencies = [ "fastly", "flate2", "futures", + "glob", "hex", "hmac", "http", diff --git a/Cargo.toml b/Cargo.toml index 0b9f42309..6770ffebd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ fastly = "0.11.12" fern = "0.7.1" flate2 = "1.1" futures = "0.3" +glob = "0.3" hex = "0.4.3" hmac = "0.12.1" http = "1.4.0" diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts new file mode 100644 index 000000000..e908a201e --- /dev/null +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface SlotRenderEvent { + isEmpty: boolean; + slot: { + getSlotElementId(): string; + getTargeting(key: string): string[]; + }; +} + +type TestWindow = Window & { + googletag?: unknown; + __ts_ad_slots?: unknown; + __ts_bids?: unknown; + __tsAdInit?: () => void; +}; + +describe('installTsAdInit', () => { + beforeEach(() => { + vi.resetModules(); + delete (window as TestWindow).__ts_ad_slots; + delete (window as TestWindow).__ts_bids; + delete (window as TestWindow).__tsAdInit; + // jsdom does not implement navigator.sendBeacon; polyfill it for tests + if (!('sendBeacon' in navigator)) { + Object.defineProperty(navigator, 'sendBeacon', { + value: vi.fn().mockReturnValue(true), + writable: true, + configurable: true, + }); + } + }); + + it('reads window.__ts_bids synchronously and applies bid targeting before refresh', async () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue(['abc']), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ]; + (window as TestWindow).__ts_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + }; + + const fetchSpy = vi.spyOn(global, 'fetch'); + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).__tsAdInit!(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }); + + it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + let capturedListener: ((e: SlotRenderEvent) => 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: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: {}, + }, + ]; + (window as TestWindow).__ts_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + }; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).__tsAdInit!(); + + expect(capturedListener).toBeDefined(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/win'); + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill'); + beaconSpy.mockRestore(); + }); + + it('does not fire nurl/burl when bid did not win GAM line item', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + + const mockSlotNoMatch = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: {}, + }, + ]; + (window as TestWindow).__ts_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + }; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).__tsAdInit!(); + capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); + + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + + it('calls refresh even when __ts_bids is empty (graceful fallback)', async () => { + const mockPubads = { + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).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 TestWindow).__ts_ad_slots = []; + (window as TestWindow).__ts_bids = {}; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).__tsAdInit!(); + + expect(mockPubads.refresh).toHaveBeenCalled(); + }); +}); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 0b9e30235..ffb4a687f 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -31,12 +31,21 @@ interface GoogleTagSlot { getAdUnitPath(): string; getSlotElementId(): string; setTargeting(key: string, value: string | string[]): GoogleTagSlot; + addService(service: GoogleTagPubAdsService): GoogleTagSlot; + getTargeting?(key: string): string[]; +} + +interface SlotRenderEndedEvent { + isEmpty: boolean; + slot: GoogleTagSlot; } interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; + addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; + refresh(): void; } interface GoogleTag { @@ -54,6 +63,7 @@ interface GoogleTag { type GptWindow = Window & { googletag?: Partial; + __tsjs_slim_prebid_url?: string; }; // ------------------------------------------------------------------ @@ -105,7 +115,8 @@ function wrapCommand(fn: () => void): () => void { */ function patchCommandQueue(tag: Partial): void { // Ensure the queue exists. - if (!Array.isArray(tag.cmd)) { + if (!tag.cmd) { + // Cast through unknown so an array satisfies the { push } type. tag.cmd = []; } @@ -121,7 +132,9 @@ function patchCommandQueue(tag: Partial): void { // Override push on the *existing* array — preserves object identity so // GPT (if already loaded) keeps its reference. - queue.push = function (...callbacks: Array<() => void>): number { + (queue as { push: (...cbs: Array<() => void>) => unknown }).push = function ( + ...callbacks: Array<() => void> + ): unknown { const wrapped = callbacks.map(wrapCommand); return originalPush(...wrapped); }; @@ -130,11 +143,15 @@ function patchCommandQueue(tag: Partial): void { (queue as { __tsPushed?: boolean }).__tsPushed = true; // Re-wrap any callbacks that were queued before we patched. - for (let i = 0; i < queue.length; i++) { - queue[i] = wrapCommand(queue[i]); + // Only applicable when cmd is an array (pre-GPT-load case). + if (Array.isArray(queue)) { + for (let i = 0; i < queue.length; i++) { + queue[i] = wrapCommand(queue[i]); + } + log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); + } else { + log.debug('GPT shim: command queue patched'); } - - log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); } /** @@ -161,6 +178,110 @@ export function installGptShim(): boolean { return true; } +// ------------------------------------------------------------------ +// Trusted Server ad-init types +// ------------------------------------------------------------------ + +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; + nurl?: string; + burl?: string; +} + +type TsWindow = Window & { + __ts_ad_slots?: TsAdSlot[]; + __ts_bids?: Record; + __tsAdInit?: () => void; +}; + +/** + * Install `window.__tsAdInit`. + * + * Reads `window.__ts_ad_slots` (injected at head-open) and `window.__ts_bids` + * (injected before ) synchronously — no fetch, no Promise. Applies bid + * targeting to GPT slots, sets the `ts_initial` sentinel, registers + * `slotRenderEnded` to fire both nurl and burl via sendBeacon when our + * specific Prebid bid wins the GAM line item match, then calls refresh(). + */ +export function installTsAdInit(): void { + const w = window as TsWindow; + w.__tsAdInit = function () { + const slots = w.__ts_ad_slots ?? []; + const bids = w.__ts_bids ?? {}; + const g = (window as GptWindow).googletag; + if (!g) return; + + g.cmd?.push(() => { + slots + .map((slot) => { + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats as Array, + slot.div_id + ); + if (!gptSlot) return null; + gptSlot.addService(g.pubads!()); + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); + const bid = bids[slot.id] ?? {}; + (['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!); + }); + gptSlot.setTargeting('ts_initial', '1'); + return { id: slot.id, gptSlot }; + }) + .filter(Boolean); + + g.pubads!().enableSingleRequest(); + g.enableServices?.(); + + g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { + const slotId: string = event.slot?.getSlotElementId?.() ?? ''; + const bid = bids[slotId] ?? {}; + const ourBidWon = + !event.isEmpty && + bid.hb_adid && + event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid; + if (ourBidWon) { + if (bid.nurl) navigator.sendBeacon(bid.nurl); + if (bid.burl) navigator.sendBeacon(bid.burl); + } + }); + + g.pubads!().refresh(); + }); + }; +} + +/** + * Register the slim-Prebid lazy loader. Fires after window.load — off the + * critical path. slim-Prebid handles refresh auctions and userID module + * warm-up (ID5, sharedID, LiveRamp ATS, Lockr). It skips initial-render slots + * (ts_initial=1) and registers as the GPT refresh handler for scroll/sticky auctions. + * + * Phase 1: no-op unless window.__tsjs_slim_prebid_url is set (it won't be until + * the slim-Prebid bundle build target ships in a later phase). + */ +export function installSlimPrebidLoader(): void { + const url = (window as GptWindow).__tsjs_slim_prebid_url; + if (!url) return; + window.addEventListener('load', () => { + const script = document.createElement('script'); + script.src = url; + script.defer = true; + document.head.appendChild(script); + }); +} + // Register the activation function on `window` so the server-injected inline // script can call it explicitly. The server emits: // `. + /// Injected at `` open. `None` when no slots matched. + pub ad_slots_script: Option, + /// Shared auction result — written by auction task before HTML processing begins. + /// Handler reads this in `el.on_end_tag()` on the body element. + /// `None` means no auction ran; inject empty `__ts_bids = {}` as fallback. + pub ad_bids_state: std::sync::Arc>>, } impl HtmlProcessorConfig { @@ -151,11 +163,18 @@ impl HtmlProcessorConfig { request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), integrations: integrations.clone(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), } } } -/// Create an HTML processor with URL replacement and integration hooks +/// Create an HTML processor with URL replacement and integration hooks. +/// +/// # Panics +/// +/// Panics if the `ad_bids_state` `RwLock` is poisoned. This cannot happen in +/// normal operation since no code holds the write lock across a panic boundary. #[must_use] pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { let post_processors = config.integrations.html_post_processors(); @@ -228,8 +247,11 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso }); let injected_tsjs = Rc::new(Cell::new(false)); + let injected_bids = Arc::new(AtomicBool::new(false)); let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); + let ad_slots_script = config.ad_slots_script.clone(); + let ad_bids_state = config.ad_bids_state.clone(); let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -238,9 +260,14 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integrations = integration_registry.clone(); let patterns = patterns.clone(); let document_state = document_state.clone(); + let ad_slots_script = ad_slots_script.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // Inject ad slots script first so it appears before tsjs bundle. + if let Some(ref slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } let ctx = IntegrationHtmlContext { request_host: &patterns.request_host, request_scheme: &patterns.request_scheme, @@ -265,6 +292,36 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) } }), + // Inject __ts_bids before via end_tag_handlers. + // Guard with AtomicBool so the script is only injected once even if + // the origin HTML contains multiple elements (e.g. template fragments). + element!("body", { + let state = ad_bids_state.clone(); + let injected_bids = injected_bids.clone(); + move |el| { + let state = state.clone(); + let injected_bids = injected_bids.clone(); + if let Some(handlers) = el.end_tag_handlers() { + let handler: EndTagHandler<'static> = Box::new( + move |end_tag: &mut EndTag<'_>| { + if injected_bids.swap(true, Ordering::SeqCst) { + return Ok(()); + } + let script_guard = state.read().expect("should read bid state"); + let bids_script = match &*script_guard { + Some(s) => s.clone(), + None => r#""# + .to_string(), + }; + end_tag.before(&bids_script, ContentType::Html); + Ok(()) + }, + ); + handlers.push(handler); + } + Ok(()) + } + }), // Replace URLs in href attributes element!("[href]", { let patterns = patterns.clone(); @@ -540,6 +597,8 @@ mod tests { request_host: "test.example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), } } @@ -1185,4 +1244,111 @@ mod tests { "should contain post-processor mutation" ); } + + #[test] + fn injects_ad_slots_at_head_open() { + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: Some( + r#""#.to_string(), + ), + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk( + b"Tcontent", + true, + ) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains("window.__ts_ad_slots"), + "should inject ad slots at head-open" + ); + assert!( + !html.contains("__ts_request_id"), + "must NOT inject request_id" + ); + } + + #[test] + fn injects_ts_bids_before_body_close() { + let bids_script = + r#""#; + let state = std::sync::Arc::new(std::sync::RwLock::new(Some(bids_script.to_string()))); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains("window.__ts_bids"), + "should inject bids before " + ); + let bids_pos = html + .find("window.__ts_bids") + .expect("bids should be in output"); + let body_close_pos = html.find("").expect(" should be in output"); + assert!(bids_pos < body_close_pos, "bids must appear before "); + } + + #[test] + fn injects_ts_bids_only_once_with_multiple_body_elements() { + let bids_script = + r#""#; + let state = std::sync::Arc::new(std::sync::RwLock::new(Some(bids_script.to_string()))); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + }; + let mut processor = create_html_processor(config); + // Malformed HTML with two elements (common in CMS template pages) + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert_eq!( + html.matches("window.__ts_bids").count(), + 1, + "should inject __ts_bids exactly once even with multiple elements" + ); + } + + #[test] + fn injects_empty_ts_bids_when_state_is_none() { + let state = std::sync::Arc::new(std::sync::RwLock::new(None)); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains("__ts_bids=JSON.parse(\"{}\")"), + "should inject empty bids on None state" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index f4ce745db..3a42ec2a0 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -133,36 +133,21 @@ impl AdServerMockProvider { .bids .iter() .map(|bid| { - // Check if this is an APS bid with encoded price (inferred from amznbid in metadata) - let encoded_price = bid - .metadata - .get("amznbid") - .and_then(|v| v.as_str()) - .map(String::from); - - if encoded_price.is_some() { - // APS bid - send encoded price for mediation to decode - json!({ - "imp_id": bid.slot_id, - "encoded_price": encoded_price, - "adm": bid.creative, - "w": bid.width, - "h": bid.height, - "crid": format!("{}-creative", bid.bidder), - "adomain": bid.adomain, - }) - } else { - // Regular bid with decoded price - json!({ - "imp_id": bid.slot_id, - "price": bid.price, - "adm": bid.creative, - "w": bid.width, - "h": bid.height, - "crid": format!("{}-creative", bid.bidder), - "adomain": bid.adomain, - }) - } + // Mocktioneer mediator always requires a numeric `price` field. + // APS bids carry price as an opaque encoded string (`amznbid`) + // that cannot be decoded client-side; use `bid.price` when set + // (a real decoded value) or fall back to a mock floor price for + // test/demo purposes. + let price = bid.price.unwrap_or(1.50); + json!({ + "imp_id": bid.slot_id, + "price": price, + "adm": bid.creative, + "w": bid.width, + "h": bid.height, + "crid": format!("{}-creative", bid.bidder), + "adomain": bid.adomain, + }) }) .collect(); @@ -256,6 +241,7 @@ impl AdServerMockProvider { }), nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), }); } @@ -507,6 +493,7 @@ mod tests { adomain: Some(vec!["amazon.com".to_string()]), nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), }], response_time_ms: 150, @@ -526,6 +513,7 @@ mod tests { adomain: None, nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), }], response_time_ms: 120, @@ -666,6 +654,7 @@ mod tests { adomain: Some(vec!["amazon.com".to_string()]), nurl: None, burl: None, + ad_id: None, metadata: aps_metadata, }], response_time_ms: 100, @@ -686,20 +675,15 @@ mod tests { let bid = &bidder_resp["bids"][0]; assert_eq!(bid["imp_id"], "slot-1"); - // Key assertions for APS-style encoded price bids: - // 1. Should NOT have "price" field (or it should be null) - assert!( - bid["price"].is_null(), - "APS bids should not have decoded price, got: {:?}", - bid["price"] - ); - // 2. Should have "encoded_price" field + // APS bids have no decoded price (bid.price == None), so the mock floor + // price (1.50) is used. Mocktioneer requires a numeric price field and + // does not accept an opaque encoded_price string. assert_eq!( - bid["encoded_price"].as_str(), - Some("encoded-price-value"), - "APS bids should have encoded_price from metadata" + bid["price"].as_f64(), + Some(1.50), + "APS bids with no decoded price should fall back to mock floor price 1.50" ); - // 3. adm should be null (not a string) + // adm should be null (not a string) assert!( bid["adm"].is_null(), "Creative-less bids should have null adm, got: {:?}", diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index af66dd064..ba6c14bbd 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -286,24 +286,46 @@ impl IntegrationConfig for ApsConfig { /// Amazon APS auction provider. pub struct ApsAuctionProvider { config: ApsConfig, + // Maps APS slot ID → creative opportunity slot ID for the in-flight request. + // Written by request_bids before the async send; read by parse_response when the + // response arrives. Safe because Fastly Compute runs each request in an isolated + // single-threaded Wasm instance — the Mutex never contends in practice. + slot_id_map: std::sync::Mutex>, } impl ApsAuctionProvider { /// Create a new APS auction provider. #[must_use] pub fn new(config: ApsConfig) -> Self { - Self { config } + Self { + config, + slot_id_map: std::sync::Mutex::new(HashMap::new()), + } } /// Convert unified `AuctionRequest` to APS TAM bid request format. /// + /// Returns the serialisable `ApsBidRequest` and a map of APS slot ID → + /// creative-opportunity slot ID so the caller can remap bids in the response. /// Populates consent fields (GDPR, US Privacy, GPP) from the /// [`ConsentContext`](crate::consent::ConsentContext) attached to the request. - fn to_aps_request(&self, request: &AuctionRequest) -> ApsBidRequest { + fn to_aps_request(&self, request: &AuctionRequest) -> (ApsBidRequest, HashMap) { + let mut slot_id_map: HashMap = HashMap::new(); let slots: Vec = request .slots .iter() .map(|slot| { + // Use the APS-specific slot ID from [slot.providers.aps] if configured; + // fall back to the creative-opportunity slot ID otherwise. + let aps_slot_id = slot + .bidders + .get("aps") + .and_then(|p| p.get("slotID")) + .and_then(|v| v.as_str()) + .unwrap_or(&slot.id) + .to_string(); + slot_id_map.insert(aps_slot_id.clone(), slot.id.clone()); + // Extract sizes from banner formats let sizes: Vec<[u32; 2]> = slot .formats @@ -313,7 +335,7 @@ impl ApsAuctionProvider { .collect(); ApsSlot { - slot_id: slot.id.clone(), + slot_id: aps_slot_id, sizes, slot_name: Some(slot.id.clone()), } @@ -337,7 +359,7 @@ impl ApsAuctionProvider { }) }); - ApsBidRequest { + let bid_request = ApsBidRequest { pub_id: self.config.pub_id.clone(), slots, page_url: request.publisher.page_url.clone(), @@ -347,7 +369,8 @@ impl ApsAuctionProvider { us_privacy, gpp, gpp_sid, - } + }; + (bid_request, slot_id_map) } /// Parse size string (e.g., "300x250") into width and height. @@ -417,6 +440,7 @@ impl ApsAuctionProvider { height, nurl: None, // Real APS uses client-side event tracking burl: None, + ad_id: None, metadata, }) } @@ -432,9 +456,19 @@ impl ApsAuctionProvider { aps_response.contextual.slots.len() ); + let slot_map = self + .slot_id_map + .lock() + .expect("should lock APS slot id map"); for slot in aps_response.contextual.slots { match self.parse_aps_slot(&slot) { - Ok(bid) => { + Ok(mut bid) => { + // Remap APS slot ID (e.g. "aps-slot-atf-sidebar") back to the + // creative-opportunity slot ID (e.g. "atf_sidebar_ad") so the + // mediator and bid_map can match by creative slot ID. + if let Some(creative_id) = slot_map.get(&bid.slot_id) { + bid.slot_id = creative_id.clone(); + } let encoded_price = bid .metadata .get("amznbid") @@ -484,8 +518,13 @@ impl AuctionProvider for ApsAuctionProvider { self.config.pub_id ); - // Transform to APS format - let aps_request = self.to_aps_request(request); + // Transform to APS format; store the APS-slot-ID → creative-slot-ID map so + // parse_response can remap bids back to the creative opportunity slot ID. + let (aps_request, slot_id_map) = self.to_aps_request(request); + *self + .slot_id_map + .lock() + .expect("should lock APS slot id map") = slot_id_map; // Serialize to JSON let aps_json = @@ -702,7 +741,7 @@ mod tests { let provider = ApsAuctionProvider::new(config); let auction_request = create_test_auction_request(); - let aps_request = provider.to_aps_request(&auction_request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&auction_request); // Verify basic fields assert_eq!(aps_request.pub_id, "5128"); @@ -728,6 +767,83 @@ mod tests { assert_eq!(slot2.sizes[0], [300, 250]); } + #[test] + fn aps_slot_id_from_bidders_map_used_in_request_and_remapped_in_response() { + use serde_json::json; + + let config = ApsConfig { + enabled: true, + pub_id: "5128".to_string(), + endpoint: default_endpoint(), + timeout_ms: 800, + }; + let provider = ApsAuctionProvider::new(config); + + let mut bidders = HashMap::new(); + bidders.insert( + "aps".to_string(), + json!({ "slotID": "aps-slot-atf-sidebar" }), + ); + let request = AuctionRequest { + id: "test".to_string(), + slots: vec![AdSlot { + id: "atf_sidebar_ad".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders, + }], + publisher: PublisherInfo { + domain: "example.com".to_string(), + page_url: None, + }, + user: UserInfo { + id: "user-1".to_string(), + fresh_id: "fresh-1".to_string(), + consent: None, + }, + device: None, + site: None, + context: HashMap::new(), + }; + + let (aps_request, slot_id_map) = provider.to_aps_request(&request); + assert_eq!( + aps_request.slots[0].slot_id, "aps-slot-atf-sidebar", + "should send configured APS slot ID to APS" + ); + assert_eq!( + slot_id_map.get("aps-slot-atf-sidebar").map(String::as_str), + Some("atf_sidebar_ad"), + "should build reverse map from APS slot ID to creative slot ID" + ); + + *provider.slot_id_map.lock().expect("should lock") = slot_id_map; + + let aps_response = json!({ + "contextual": { + "slots": [{ + "slotID": "aps-slot-atf-sidebar", + "size": "300x250", + "fif": "1", + "amznbid": "1gtm3q", + "meta": ["slotID"] + }] + } + }); + + let response = provider.parse_aps_response(&aps_response, 100); + assert_eq!(response.bids.len(), 1, "should parse one bid"); + assert_eq!( + response.bids[0].slot_id, "atf_sidebar_ad", + "bid slot_id should be remapped to creative slot ID" + ); + } + #[test] fn test_aps_response_parsing_success() { let config = ApsConfig { @@ -956,7 +1072,7 @@ mod tests { ..Default::default() }); - let aps_request = provider.to_aps_request(&request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&request); // Verify GDPR consent let gdpr = aps_request.gdpr.expect("should have gdpr"); @@ -985,7 +1101,7 @@ mod tests { let provider = ApsAuctionProvider::new(config); let request = create_test_auction_request(); // consent is None - let aps_request = provider.to_aps_request(&request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&request); assert!(aps_request.gdpr.is_none()); assert!(aps_request.us_privacy.is_none()); @@ -1012,7 +1128,7 @@ mod tests { ..Default::default() }); - let aps_request = provider.to_aps_request(&request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&request); let json = serde_json::to_value(&aps_request).expect("should serialize"); // GDPR fields present diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 40bcf7f2c..796d633e1 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -438,13 +438,42 @@ impl IntegrationHeadInjector for GptIntegration { } fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - // Set the enable flag and best-effort call the activation function - // registered by the GPT shim module. The bundle also auto-installs - // when it sees the pre-set flag, so this works regardless of whether - // the inline bootstrap runs before or after the TSJS bundle. vec![ - "" + "" .to_string(), + concat!( + "" + ).to_string(), ] } } @@ -1020,7 +1049,7 @@ mod tests { let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should emit exactly one head insert"); + assert_eq!(inserts.len(), 2, "should emit exactly two head inserts"); assert_eq!( inserts[0], "", @@ -1028,6 +1057,54 @@ mod tests { ); } + #[test] + fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let inserts = integration.head_inserts(&ctx); + let combined = inserts.join(""); + assert!(combined.contains("__tsAdInit"), "should define __tsAdInit"); + assert!( + combined.contains("window.__ts_bids"), + "should read window.__ts_bids synchronously" + ); + assert!( + combined.contains("ts_initial"), + "should set ts_initial sentinel" + ); + assert!( + combined.contains("slotRenderEnded"), + "should register slotRenderEnded" + ); + assert!( + combined.contains("sendBeacon"), + "should fire nurl and burl via sendBeacon" + ); + assert!( + combined.contains("nurl"), + "should fire nurl on confirmed render" + ); + assert!( + !combined.contains("/ts-bids"), + "must NOT fetch /ts-bids — bids are inline on the page" + ); + assert!( + !combined.contains("bidsPromise"), + "must NOT use bidsPromise — bids are synchronous" + ); + assert!( + !combined.contains("__ts_request_id"), + "must NOT reference request_id — no longer used" + ); + } + #[test] fn head_injector_integration_id() { let integration = GptIntegration::new(test_config()); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 6dfd1ca1a..46b87cc0e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -26,8 +26,8 @@ use crate::integrations::{ }; use crate::openrtb::{ to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, - OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, - TrustedServerExt, User, UserExt, + ImpStoredRequest, OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, + RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, }; use crate::platform::RuntimeServices; use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; @@ -119,6 +119,10 @@ 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 { @@ -525,22 +529,27 @@ impl PrebidAuctionProvider { // Build the bidder map for PBS. // The JS adapter sends "trustedServer" as the bidder (our orchestrator // adapter name). Replace it with the real PBS bidders from config. - // Pass through any other bidders with their params as-is. + // Only pass through keys that are known PBS bidders — skip provider-specific + // keys like "aps" which belong to their own separate auction provider. let mut bidder: HashMap = HashMap::new(); for (name, params) in &slot.bidders { if name == TRUSTED_SERVER_BIDDER { bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); - } else { + } else if self.config.bidders.iter().any(|b| b == name) { bidder.insert(name.clone(), params.clone()); } } - // Fallback to config bidders if none provided - if bidder.is_empty() { - for b in &self.config.bidders { - bidder.insert(b.clone(), Json::Object(serde_json::Map::new())); - } - } + // When no inline PBS bidder params exist (e.g. creative-opportunity slots + // whose PBS params live in stored requests), tell PBS to resolve bidder + // config from the stored request keyed by this slot ID. + let storedrequest = if bidder.is_empty() { + Some(ImpStoredRequest { + id: slot.id.clone(), + }) + } else { + None + }; // Apply zone-specific bid param overrides when configured. for (name, params) in &mut bidder { @@ -578,7 +587,10 @@ impl PrebidAuctionProvider { secure: Some(true), // require HTTPS creatives tagid: Some(slot.id.clone()), ext: ImpExt { - prebid: PrebidImpExt { bidder }, + prebid: PrebidImpExt { + bidder, + storedrequest, + }, } .to_ext(), ..Default::default() @@ -966,6 +978,12 @@ impl PrebidAuctionProvider { .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); + let ad_id = bid_obj + .get("adid") + .or_else(|| bid_obj.get("id")) + .and_then(|v| v.as_str()) + .map(String::from); + let adomain = bid_obj .get("adomain") .and_then(|v| v.as_array()) @@ -986,6 +1004,7 @@ impl PrebidAuctionProvider { height, nurl, burl, + ad_id, metadata: std::collections::HashMap::new(), }) } @@ -1258,9 +1277,16 @@ mod tests { client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::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(), @@ -3026,4 +3052,90 @@ fixed_bottom = {placementId = "_s2sBottom"} assert_eq!(statuses[0]["bidder"], "kargo"); assert_eq!(statuses[1]["status"], "timeout"); } + + // ======================================================================== + // PBS stored request tests + // ======================================================================== + + #[test] + fn to_openrtb_uses_stored_request_when_slot_has_no_pbs_bidder_params() { + // Slot only has "aps" provider — not a PBS bidder + let slot = make_slot( + "atf_sidebar_ad", + HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot-atf-sidebar"}))]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should not send inline bidder params when using stored request" + ); + assert_eq!( + prebid["storedrequest"]["id"], "atf_sidebar_ad", + "should use slot id as stored request id" + ); + } + + #[test] + fn to_openrtb_uses_stored_request_when_slot_has_empty_bidders() { + let slot = make_slot("homepage_header_ad", HashMap::new()); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert_eq!( + prebid["storedrequest"]["id"], "homepage_header_ad", + "should use slot id as stored request id for slot with no bidder map" + ); + } + + #[test] + fn to_openrtb_uses_inline_bidder_params_not_stored_request_for_trusted_server_slots() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + + let slot = make_ts_slot( + "in_content_ad", + &json!({ "kargo": { "placementId": "client_123" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("storedrequest").is_none(), + "should not use stored request when inline bidder params are present" + ); + assert_eq!( + prebid["bidder"]["kargo"]["placementId"], "client_123", + "should use inline bidder params from trustedServer expansion" + ); + } + + #[test] + fn to_openrtb_skips_aps_key_from_slot_bidders_in_pbs_request() { + let slot = make_slot( + "atf_sidebar_ad", + HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot-atf-sidebar"}))]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should not forward aps key into PBS imp.ext.prebid.bidder" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 8b55493be..ffad78921 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -853,6 +853,14 @@ impl IntegrationRegistry { .collect() } + #[cfg(test)] + #[must_use] + pub fn empty_for_tests() -> Self { + Self { + inner: Arc::new(IntegrationRegistryInner::default()), + } + } + #[cfg(test)] #[must_use] pub fn from_rewriters( diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 44fa108dd..4e5ef76bb 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -40,6 +40,7 @@ pub mod consent_config; pub mod constants; pub mod cookies; pub mod creative; +pub mod creative_opportunities; pub mod edge_cookie; pub mod error; pub mod geo; @@ -50,6 +51,7 @@ pub mod integrations; pub mod models; pub mod openrtb; pub mod platform; +pub mod price_bucket; pub mod proxy; pub mod publisher; pub mod redacted; diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 3c9be932e..eca5e70f5 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -162,9 +162,21 @@ pub struct ImpExt { impl ToExt for ImpExt {} -#[derive(Debug, Serialize)] +#[derive(Debug, Default, Serialize)] pub struct PrebidImpExt { + #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] pub bidder: std::collections::HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub storedrequest: Option, +} + +/// PBS imp-level stored request reference. +/// +/// PBS merges the stored imp JSON (keyed by `id`) into the outgoing request, +/// populating bidder params that are not sent inline. +#[derive(Debug, Serialize)] +pub struct ImpStoredRequest { + pub id: String, } #[derive(Debug, Serialize)] diff --git a/crates/trusted-server-core/src/price_bucket.rs b/crates/trusted-server-core/src/price_bucket.rs new file mode 100644 index 000000000..b683020bf --- /dev/null +++ b/crates/trusted-server-core/src/price_bucket.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PriceGranularity { + Low, + Medium, + #[default] + Dense, + High, + Auto, +} + +impl PriceGranularity { + #[must_use] + pub fn dense() -> Self { + Self::Dense + } +} + +#[must_use] +pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { + if cpm <= 0.0 { + return "0.00".to_string(); + } + match granularity { + PriceGranularity::Low => { + let capped = cpm.min(5.0); + let cents = (capped * 100.0).floor() as u64; + let bucketed_cents = (cents / 50) * 50; + format!("{:.2}", bucketed_cents as f64 / 100.0) + } + PriceGranularity::Medium => { + let capped = cpm.min(20.0); + let cents = (capped * 100.0).floor() as u64; + let bucketed_cents = (cents / 10) * 10; + format!("{:.2}", bucketed_cents as f64 / 100.0) + } + PriceGranularity::High => { + let capped = cpm.min(20.0); + let cents = (capped * 100.0).floor() as u64; + format!("{:.2}", cents as f64 / 100.0) + } + PriceGranularity::Dense | PriceGranularity::Auto => dense_bucket(cpm), + } +} + +fn dense_bucket(cpm: f64) -> String { + if cpm >= 20.0 { + return "20.00".to_string(); + } + if cpm >= 8.0 { + let cents = (cpm * 100.0).floor() as u64; + let bucketed_cents = (cents / 50) * 50; + return format!("{:.2}", bucketed_cents as f64 / 100.0); + } + if cpm >= 3.0 { + let cents = (cpm * 100.0).floor() as u64; + let bucketed_cents = (cents / 5) * 5; + return format!("{:.2}", bucketed_cents as f64 / 100.0); + } + let cents = (cpm * 100.0).floor() as u64; + format!("{:.2}", cents as f64 / 100.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dense_below_3_increments_by_0_01() { + assert_eq!(price_bucket(0.0, PriceGranularity::Dense), "0.00"); + assert_eq!(price_bucket(0.01, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(0.015, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(1.23, PriceGranularity::Dense), "1.23"); + assert_eq!(price_bucket(2.99, PriceGranularity::Dense), "2.99"); + } + + #[test] + fn dense_3_to_8_increments_by_0_05() { + assert_eq!(price_bucket(3.00, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.03, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.05, PriceGranularity::Dense), "3.05"); + assert_eq!(price_bucket(7.99, PriceGranularity::Dense), "7.95"); + } + + #[test] + fn dense_8_to_20_increments_by_0_50() { + assert_eq!(price_bucket(8.00, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.49, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.50, PriceGranularity::Dense), "8.50"); + assert_eq!(price_bucket(19.99, PriceGranularity::Dense), "19.50"); + } + + #[test] + fn dense_above_20_caps_at_20() { + assert_eq!(price_bucket(20.00, PriceGranularity::Dense), "20.00"); + assert_eq!(price_bucket(50.00, PriceGranularity::Dense), "20.00"); + } + + #[test] + fn low_increments_by_0_50_capped_at_5() { + assert_eq!(price_bucket(0.49, PriceGranularity::Low), "0.00"); + assert_eq!(price_bucket(0.50, PriceGranularity::Low), "0.50"); + assert_eq!(price_bucket(5.01, PriceGranularity::Low), "5.00"); + } + + #[test] + fn medium_increments_by_0_10_capped_at_20() { + assert_eq!(price_bucket(1.05, PriceGranularity::Medium), "1.00"); + assert_eq!(price_bucket(1.10, PriceGranularity::Medium), "1.10"); + assert_eq!(price_bucket(20.5, PriceGranularity::Medium), "20.00"); + } + + #[test] + fn high_increments_by_0_01_capped_at_20() { + assert_eq!(price_bucket(1.234, PriceGranularity::High), "1.23"); + assert_eq!(price_bucket(20.5, PriceGranularity::High), "20.00"); + } + + #[test] + fn auto_routes_through_dense() { + assert_eq!( + price_bucket(2.53, PriceGranularity::Auto), + price_bucket(2.53, PriceGranularity::Dense) + ); + } +} diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5bcef6941..c7744ed2e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12,11 +12,16 @@ //! content-rewriting concern. use std::io::Write; +use std::sync::{Arc, RwLock}; use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; +use crate::auction::orchestrator::AuctionOrchestrator; +use crate::auction::types::{ + AuctionContext, AuctionRequest, Bid, PublisherInfo, SiteInfo, UserInfo, +}; use crate::backend::BackendConfig; use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_TS_EC, HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; @@ -26,6 +31,7 @@ use crate::error::TrustedServerError; use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::integrations::IntegrationRegistry; use crate::platform::RuntimeServices; +use crate::price_bucket::price_bucket; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -182,6 +188,8 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, + ad_bids_state: &'a Arc>>, } /// Process response body through the streaming pipeline. @@ -224,6 +232,8 @@ fn process_response_streaming( params.request_scheme, params.settings, params.integration_registry, + params.ad_slots_script.map(str::to_string), + params.ad_bids_state.clone(), )?; StreamingPipeline::new(config, processor).process(body, output)?; } else if is_rsc_flight { @@ -252,18 +262,21 @@ fn create_html_stream_processor( origin_host: &str, request_host: &str, request_scheme: &str, - settings: &Settings, + _settings: &Settings, integration_registry: &IntegrationRegistry, + ad_slots_script: Option, + ad_bids_state: Arc>>, ) -> Result> { use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - let config = HtmlProcessorConfig::from_settings( - settings, - integration_registry, - origin_host, - request_host, - request_scheme, - ); + let config = HtmlProcessorConfig { + origin_host: origin_host.to_string(), + request_host: request_host.to_string(), + request_scheme: request_scheme.to_string(), + integrations: integration_registry.clone(), + ad_slots_script, + ad_bids_state, + }; Ok(create_html_processor(config)) } @@ -392,6 +405,8 @@ pub struct OwnedProcessResponseParams { pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, + pub(crate) ad_slots_script: Option, + pub(crate) ad_bids_state: Arc>>, } /// Stream the publisher response body through the processing pipeline. @@ -420,6 +435,8 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + ad_bids_state: ¶ms.ad_bids_state, }; process_response_streaming(body, output, &borrowed) } @@ -441,10 +458,18 @@ pub fn stream_publisher_body( /// /// Returns a [`TrustedServerError`] if the proxy request fails or the /// origin backend is unreachable. -pub fn handle_publisher_request( +/// +/// # Panics +/// +/// Panics if `should_run_auction` is `true` but `settings.creative_opportunities` is `None`. +/// This is a logic invariant: `should_run_auction` is only set when creative opportunities +/// are configured, so this state is unreachable in practice. +pub async fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, services: &RuntimeServices, + orchestrator: &AuctionOrchestrator, + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, mut req: Request, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -520,14 +545,111 @@ pub fn handle_publisher_request( backend_name, settings.publisher.origin_url ); + + let request_path = req.get_path().to_string(); + let is_get = req.get_method() == fastly::http::Method::GET; + + let is_prefetch = req + .get_header_str("sec-purpose") + .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)); + + let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { + crate::creative_opportunities::match_slots(&slots_file.slots, &request_path) + .into_iter() + .cloned() + .collect() + } else { + Vec::new() + }; + + let consent_allows_auction = consent_context + .tcf + .as_ref() + .is_some_and(|tcf| tcf.has_purpose_consent(1)); + + let should_run_auction = + is_get && !is_prefetch && !is_bot && !matched_slots.is_empty() && consent_allows_auction; + + let auction_timeout_ms = settings + .creative_opportunities + .as_ref() + .and_then(|co| co.auction_timeout_ms) + .unwrap_or(settings.auction.timeout_ms); + + let ad_bids_state: Arc>> = Arc::new(RwLock::new(None)); + // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); req.set_header("host", &origin_host); - let mut response = req - .send(&backend_name) + let pending_origin = + req.send_async(&backend_name) + .change_context(TrustedServerError::Proxy { + message: "Failed to dispatch async origin request".to_string(), + })?; + + let auction_result = if should_run_auction { + let co_config = settings + .creative_opportunities + .as_ref() + .expect("should be present when should_run_auction is true"); + let auction_request = build_auction_request( + &matched_slots, + &ec_id, + &consent_context, + &request_info, + co_config, + ); + let placeholder_req = fastly::Request::get("https://placeholder.invalid/"); + let auction_context = AuctionContext { + settings, + request: &placeholder_req, + client_info: services.client_info(), + timeout_ms: auction_timeout_ms, + provider_responses: None, + services, + }; + match orchestrator + .run_auction(&auction_request, &auction_context, services) + .await + { + Ok(result) => Some(result), + Err(e) => { + log::warn!("server-side auction failed, proceeding without bids: {e:?}"); + None + } + } + } else { + None + }; + + if should_run_auction { + let co_config = settings + .creative_opportunities + .as_ref() + .expect("should be present"); + let empty: std::collections::HashMap = std::collections::HashMap::new(); + let winning_bids = auction_result + .as_ref() + .map(|r| &r.winning_bids) + .unwrap_or(&empty); + let bid_map = build_bid_map(winning_bids, co_config.price_granularity); + let bids_script = build_bids_script(&bid_map); + *ad_bids_state.write().expect("should write bid state") = Some(bids_script); + } + + let mut response = pending_origin + .wait() .change_context(TrustedServerError::Proxy { - message: "Failed to proxy request to origin".to_string(), + message: "Failed to await origin response".to_string(), })?; log::debug!("Response headers:"); @@ -535,6 +657,22 @@ pub fn handle_publisher_request( log::debug!(" {}: {:?}", name, value); } + let ad_slots_script = if let Some(co_config) = &settings.creative_opportunities { + if !matched_slots.is_empty() { + Some(build_ad_slots_script(&matched_slots, co_config)) + } else { + None + } + } else { + None + }; + + if ad_slots_script.is_some() { + response.set_header(header::CACHE_CONTROL, "private, no-store"); + response.remove_header("surrogate-control"); + response.remove_header("fastly-surrogate-control"); + } + // Set EC ID / cookie headers BEFORE body processing. // These are body-independent (computed from request cookies + consent). apply_ec_headers( @@ -623,6 +761,8 @@ pub fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + ad_slots_script: ad_slots_script.clone(), + ad_bids_state: ad_bids_state.clone(), }, }) } @@ -642,6 +782,8 @@ pub fn handle_publisher_request( settings, content_type: &content_type, integration_registry, + ad_slots_script: ad_slots_script.as_deref(), + ad_bids_state: &ad_bids_state, }; let mut output = Vec::new(); process_response_streaming(body, &mut output, ¶ms)?; @@ -654,6 +796,138 @@ pub fn handle_publisher_request( } } +/// Build an [`AuctionRequest`] from matched creative opportunity slots. +pub(crate) fn build_auction_request( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + ec_id: &str, + consent_context: &crate::consent::ConsentContext, + request_info: &crate::http_util::RequestInfo, + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, +) -> AuctionRequest { + let slots = matched_slots + .iter() + .map(|s| s.to_ad_slot(&co_config.gam_network_id)) + .collect(); + AuctionRequest { + id: format!("ts-{}", ec_id), + slots, + publisher: PublisherInfo { + domain: request_info.host.clone(), + page_url: None, + }, + user: UserInfo { + id: ec_id.to_string(), + fresh_id: ec_id.to_string(), + consent: Some(consent_context.clone()), + }, + device: None, + site: Some(SiteInfo { + domain: request_info.host.clone(), + page: String::new(), + }), + context: std::collections::HashMap::new(), + } +} + +/// Escape a JSON string so it is safe to embed inside a JS double-quoted string literal. +/// +/// Backslashes are doubled first (so they survive the next pass), then +/// double-quotes are escaped so they do not terminate the JS string. +/// The result is always valid to write as `JSON.parse("…")`. +fn html_escape_for_script(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Build a price-bucketed bid map from winning bids. +/// +/// Returns a JSON object map of slot ID → bid metadata including the bucketed +/// CPM (`hb_pb`), bidder (`hb_bidder`), and optional ad ID, nurl, and burl. +pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap, + granularity: crate::price_bucket::PriceGranularity, +) -> serde_json::Map { + winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + bid.price.map(|cpm| { + let bucket = price_bucket(cpm, granularity); + let mut obj = serde_json::Map::new(); + obj.insert("hb_pb".to_string(), serde_json::Value::String(bucket)); + obj.insert( + "hb_bidder".to_string(), + serde_json::Value::String(bid.bidder.clone()), + ); + if let Some(ref ad_id) = bid.ad_id { + obj.insert( + "hb_adid".to_string(), + serde_json::Value::String(ad_id.clone()), + ); + } + if let Some(ref nurl) = bid.nurl { + obj.insert("nurl".to_string(), serde_json::Value::String(nurl.clone())); + } + if let Some(ref burl) = bid.burl { + obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); + } + (slot_id.clone(), serde_json::Value::Object(obj)) + }) + }) + .collect() +} + +/// Build the `__ts_bids` `` sequences inside the string. +pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { + let json = serde_json::to_string(bid_map).unwrap_or_else(|_| "{}".to_string()); + let escaped = html_escape_for_script(&json); + format!( + "", + escaped + ) +} + +/// Build the `__ts_ad_slots` `", + escaped + ) +} + /// Whether the content type requires processing (URL rewriting, HTML injection). /// /// Text-based and JavaScript/JSON responses are processable; binary types @@ -1366,6 +1640,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); @@ -1407,6 +1683,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); @@ -1439,6 +1717,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let bogus_body = Body::from(b"not gzip".to_vec()); @@ -1538,6 +1818,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -1588,6 +1870,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); @@ -1614,4 +1898,204 @@ mod tests { "origin host must not leak. Got: {processed}" ); } + + #[cfg(test)] + mod creative_opportunities_tests { + use super::super::{ + build_ad_slots_script, build_bid_map, build_bids_script, html_escape_for_script, + }; + use crate::auction::types::{Bid, MediaType}; + use crate::creative_opportunities::{ + CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, + }; + 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(), + } + } + + fn make_bid( + slot_id: &str, + price: f64, + bidder: &str, + ad_id: &str, + nurl: &str, + burl: &str, + ) -> Bid { + Bid { + slot_id: slot_id.to_string(), + price: Some(price), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: Some(nurl.to_string()), + burl: Some(burl.to_string()), + ad_id: Some(ad_id.to_string()), + metadata: Default::default(), + } + } + + #[test] + fn ad_slots_script_contains_slot_data() { + 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"); + assert!(!script.contains("__ts_bids"), "must NOT contain bids"); + assert!( + !script.contains("__ts_request_id"), + "must NOT contain request_id" + ); + } + + #[test] + fn ad_slots_script_is_xss_safe() { + let slots = vec![make_slot()]; + let config = make_config(); + let script = build_ad_slots_script(&slots, &config); + let inner = script + .trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in script content"); + assert!(!inner.contains('>'), "no unescaped > in script content"); + } + + #[test] + fn bid_map_includes_nurl_and_burl() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ), + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); + let obj = entry.as_object().expect("should be object"); + assert_eq!( + obj.get("hb_pb").and_then(|v| v.as_str()), + Some("1.50"), + "should bucket price with dense granularity" + ); + assert_eq!( + obj.get("hb_bidder").and_then(|v| v.as_str()), + Some("kargo"), + "should include bidder" + ); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("abc123"), + "should include ad_id" + ); + assert_eq!( + obj.get("nurl").and_then(|v| v.as_str()), + Some("https://ssp/win"), + "should include nurl" + ); + assert_eq!( + obj.get("burl").and_then(|v| v.as_str()), + Some("https://ssp/bill"), + "should include burl" + ); + } + + #[test] + fn bid_map_excludes_slot_when_price_is_none() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "no-price-slot".to_string(), + Bid { + slot_id: "no-price-slot".to_string(), + price: None, + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + assert!( + map.is_empty(), + "slot with no price should be excluded from bid map" + ); + } + + #[test] + fn bids_script_is_xss_safe() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + let script = build_bids_script(&map); + let inner = script + .trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in bids script"); + assert!(!inner.contains('>'), "no unescaped > in bids script"); + } + + #[test] + fn html_escape_encodes_special_chars() { + assert_eq!( + html_escape_for_script("text\\with\\backslash"), + "text\\\\with\\\\backslash", + "should escape backslashes" + ); + assert_eq!( + html_escape_for_script("string\"with\"quotes"), + "string\\\"with\\\"quotes", + "should escape quotes" + ); + assert_eq!( + html_escape_for_script("simple"), + "simple", + "should not change simple text" + ); + assert_eq!( + html_escape_for_script("both\\\"mixed"), + "both\\\\\\\"mixed", + "should escape both backslashes and quotes" + ); + } + } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 785492620..517c09550 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -11,6 +11,7 @@ use validator::{Validate, ValidationError}; use crate::auction_config_types::AuctionConfig; use crate::consent_config::ConsentConfig; +use crate::creative_opportunities::CreativeOpportunitiesConfig; use crate::error::TrustedServerError; use crate::redacted::Redacted; @@ -423,6 +424,8 @@ pub struct Settings { pub consent: ConsentConfig, #[serde(default)] pub proxy: Proxy, + #[serde(default)] + pub creative_opportunities: Option, } #[allow(unused)] @@ -1920,6 +1923,32 @@ mod tests { /// /// If this test fails, a route was added or removed in the Fastly /// router without updating `ADMIN_ENDPOINTS` (or vice versa). + #[test] + fn settings_parses_creative_opportunities_section() { + let toml = r#" +[[handlers]] +path = "^/admin" +username = "admin" +password = "changeme" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +proxy_secret = "secret" + +[creative_opportunities] +gam_network_id = "21765378893" +auction_timeout_ms = 500 +"#; + let settings = Settings::from_toml(toml).expect("should parse"); + let co = settings + .creative_opportunities + .expect("should have creative_opportunities"); + assert_eq!(co.gam_network_id, "21765378893"); + assert_eq!(co.auction_timeout_ms, Some(500)); + } + #[test] fn admin_endpoints_match_fastly_router() { let router_source = include_str!("../../trusted-server-adapter-fastly/src/main.rs"); diff --git a/creative-opportunities.toml b/creative-opportunities.toml new file mode 100644 index 000000000..3cd27f2b1 --- /dev/null +++ b/creative-opportunities.toml @@ -0,0 +1,40 @@ +# Slot templates for server-side ad auction. +# Empty file = feature disabled (no auction fired, no globals injected). + +[[slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/88059007/autoblog/news" +div_id = "ad-atf_sidebar-0-_r_2_" +page_patterns = ["/20**", "/news/**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" + +[slot.providers.pbs.bidders] +mocktioneer = { bid = 2.00 } +criteo = { networkId = 123456, pubid = "123456" } + +[[slot]] +id = "homepage_header_ad" +gam_unit_path = "/88059007/autoblog/homepage" +div_id = "ad-header-0-_R_jpalubtak5lb_" +page_patterns = ["/"] +formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] +floor_price = 0.50 + +[slot.targeting] +pos = "atf" +zone = "header" + +[slot.providers.aps] +slot_id = "aps-slot-homepage-header" + +[slot.providers.pbs.bidders] +mocktioneer = { bid = 2.00 } +criteo = { networkId = 123456, pubid = "123456" } 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 new file mode 100644 index 000000000..0429bd9ac --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md @@ -0,0 +1,2004 @@ +# Server-Side Ad Templates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable the Fastly edge to fire a full header-bidding auction (PBS + APS) in parallel with the origin fetch, inject `window.__ts_ad_slots` into ``, then inject `window.__ts_bids` inline before `` so the client drives GPT directly — without Prebid.js and without blocking FCP. + +**Architecture:** A new `creative-opportunities.toml` holds per-URL slot templates. At request time the publisher path matches the URL and fires the auction + origin fetch concurrently via `send_async()`. `window.__ts_ad_slots` is injected at `` open with no auction wait. The `` close tag is held — bounded by `A_deadline` — until the auction completes or times out; `window.__ts_bids` is then injected inline before the close tag so bids and HTML travel together in a single response. The client's `__tsAdInit` reads `window.__ts_bids` synchronously (no fetch, no Promise) and drives GPT. Both `nurl` and `burl` fire client-side from `slotRenderEnded` to avoid billing inflation. A slim-Prebid bundle lazy-loads post-`window.load` for refresh auctions and identity warm-up. + +**Tech Stack:** Rust 2024, `lol_html` 2.7.2 (existing), `glob` crate (new workspace dep), `serde`/`toml` (existing), `std::sync::{Arc, RwLock}` for within-request shared auction state, `AuctionOrchestrator::run_auction` (existing `async fn`), TypeScript for GPT shim extension. + +> **Phase 1 streaming note:** The spec describes true streaming where body content above `` paints before the auction completes. Implementing this with lol_html's synchronous callback model requires a complex outer streaming loop (emit chunks as they arrive from origin; hold only the `` chunk until auction resolves). Phase 1 uses a simpler approach: await the auction (or `A_deadline`) before processing origin HTML through lol_html, then send the fully assembled response. This still delivers the server-side auction benefit and achieves the same ad-visible latency target (~870ms cache hit). The FCP claim (~80ms) of the spec requires the streaming approach and is tracked as a Phase 2 optimization. The shared `Arc>>` is the correct coordination primitive either way — the Phase 2 upgrade path only changes when the auction is awaited relative to lol_html processing. + +--- + +## File Map + +### New files + +| File | Responsibility | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `creative-opportunities.toml` | Slot template definitions (page patterns, formats, floor prices, per-provider params) | +| `crates/trusted-server-core/src/creative_opportunities.rs` | Config types, TOML parsing, URL glob matching, slot→`AdSlot` conversion, startup validation | +| `crates/trusted-server-core/src/price_bucket.rs` | Prebid price granularity tables; converts `f64` CPM to `hb_pb` string | + +### Modified files + +| File | Change summary | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Cargo.toml` | Add `glob = "0.3"` to `[workspace.dependencies]` | +| `crates/trusted-server-core/Cargo.toml` | Add `glob = { workspace = true }` | +| `crates/trusted-server-core/Cargo.toml` (build-deps) | Verify `regex` is listed under `[build-dependencies]` (needed for slot-ID validation in `build.rs`) | +| `crates/trusted-server-core/src/auction/types.rs` | Add `MediaType::banner()` constructor; add `ad_id: Option` to `Bid` | +| `crates/trusted-server-core/src/settings.rs` | Add `creative_opportunities: Option` to `Settings` | +| `trusted-server.toml` | Add `[creative_opportunities]` section | +| `crates/trusted-server-core/build.rs` | Validate slot IDs at build time using inline TOML parse; `rerun-if-changed` for `creative-opportunities.toml` | +| `crates/trusted-server-core/src/html_processor.rs` | Add `ad_slots_script: Option` (head) and `ad_bids_script: Arc>>` (body) to `HtmlProcessorConfig`; inject at `` open and via `el.on_end_tag()` on body element | +| `crates/trusted-server-core/src/publisher.rs` | Convert to `async fn`; add eligibility gates (bot UA, prefetch, HEAD); EID decoration; shared `AuctionBidState`; inject `__ts_bids` before ``; `Cache-Control: private, max-age=0`; strip `Surrogate-Control` | +| `crates/trusted-server-adapter-fastly/src/main.rs` | Await the now-async handler; pass orchestrator reference; no `/ts-bids` route | +| `crates/trusted-server-core/src/integrations/gpt.rs` | Extend `head_inserts()` to emit `__tsAdInit` that reads `window.__ts_bids` synchronously; `ts_initial=1` sentinel; nurl+burl from `slotRenderEnded` | +| `crates/js/lib/src/integrations/gpt/index.ts` | Synchronous `__tsAdInit` with `window.__ts_bids` read; nurl+burl `sendBeacon`; lazy slim-Prebid loader post-`window.load` | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Add `suppress_nurl: bool` config (default `false`) as per-bidder escape hatch; remove server-side nurl firing | + +### Deleted (relative to prior revision of this spec) + +- `crates/trusted-server-core/src/bid_cache.rs` — never created; in-process cache rejected because Fastly Compute per-request Wasm isolates are not pinned across requests +- `/ts-bids` endpoint — never created; body-injection replaces the fetch pattern + +--- + +## Task 1: Add `glob` workspace dependency + +**Files:** + +- Modify: `Cargo.toml` +- Modify: `crates/trusted-server-core/Cargo.toml` + +- [ ] **Step 1: Write a failing compile test** + + In `crates/trusted-server-core/src/lib.rs`, temporarily add: + + ```rust + // Compilation test — remove after Step 4 + use glob::Pattern as _; + ``` + + Run: `cargo check --package trusted-server-core` + Expected: error `use of undeclared crate or module 'glob'` + +- [ ] **Step 2: Add glob to workspace `Cargo.toml`** + + Under `[workspace.dependencies]`, add: + + ```toml + glob = "0.3" + ``` + +- [ ] **Step 3: Add glob to core crate** + + In `crates/trusted-server-core/Cargo.toml` under `[dependencies]`, add: + + ```toml + glob = { workspace = true } + ``` + +- [ ] **Step 4: Remove temp import, verify compile** + + Remove the temp `use glob::Pattern as _;` from `lib.rs`. + Run: `cargo check --package trusted-server-core` + Expected: clean compile + +- [ ] **Step 5: Commit** + + ```bash + git add Cargo.toml crates/trusted-server-core/Cargo.toml + git commit -m "Add glob workspace dependency for URL pattern matching" + ``` + +--- + +## Task 2: `price_bucket.rs` — Prebid price granularity + +**Files:** + +- Create: `crates/trusted-server-core/src/price_bucket.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +The `hb_pb` value in bid responses is a discretized bucket string from Prebid's granularity tables. "Dense" is the default used in most Prebid deployments. + +- [ ] **Step 1: Write failing tests** + + Create `crates/trusted-server-core/src/price_bucket.rs` with only the tests: + + ```rust + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn dense_below_3_increments_by_0_01() { + assert_eq!(price_bucket(0.0, PriceGranularity::Dense), "0.00"); + assert_eq!(price_bucket(0.01, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(0.015, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(1.23, PriceGranularity::Dense), "1.23"); + assert_eq!(price_bucket(2.99, PriceGranularity::Dense), "2.99"); + } + + #[test] + fn dense_3_to_8_increments_by_0_05() { + assert_eq!(price_bucket(3.00, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.03, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.05, PriceGranularity::Dense), "3.05"); + assert_eq!(price_bucket(7.99, PriceGranularity::Dense), "7.95"); + } + + #[test] + fn dense_8_to_20_increments_by_0_50() { + assert_eq!(price_bucket(8.00, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.49, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.50, PriceGranularity::Dense), "8.50"); + assert_eq!(price_bucket(19.99, PriceGranularity::Dense), "19.50"); + } + + #[test] + fn dense_above_20_caps_at_20() { + assert_eq!(price_bucket(20.00, PriceGranularity::Dense), "20.00"); + assert_eq!(price_bucket(50.00, PriceGranularity::Dense), "20.00"); + } + + #[test] + fn low_increments_by_0_50_capped_at_5() { + assert_eq!(price_bucket(0.49, PriceGranularity::Low), "0.00"); + assert_eq!(price_bucket(0.50, PriceGranularity::Low), "0.50"); + assert_eq!(price_bucket(5.01, PriceGranularity::Low), "5.00"); + } + + #[test] + fn medium_increments_by_0_10_capped_at_20() { + assert_eq!(price_bucket(1.05, PriceGranularity::Medium), "1.00"); + assert_eq!(price_bucket(1.10, PriceGranularity::Medium), "1.10"); + assert_eq!(price_bucket(20.5, PriceGranularity::Medium), "20.00"); + } + + #[test] + fn high_increments_by_0_01_capped_at_20() { + assert_eq!(price_bucket(1.234, PriceGranularity::High), "1.23"); + assert_eq!(price_bucket(20.5, PriceGranularity::High), "20.00"); + } + + #[test] + fn auto_routes_through_dense() { + assert_eq!( + price_bucket(2.53, PriceGranularity::Auto), + price_bucket(2.53, PriceGranularity::Dense) + ); + } + } + ``` + + Run: `cargo test -p trusted-server-core price_bucket` + Expected: compile error (module not yet exported from lib.rs) + +- [ ] **Step 2: Implement price_bucket.rs** + + ```rust + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] + #[serde(rename_all = "lowercase")] + pub enum PriceGranularity { + Low, + Medium, + #[default] + Dense, + High, + Auto, + } + + impl PriceGranularity { + pub fn dense() -> Self { + Self::Dense + } + } + + pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { + if cpm <= 0.0 { + return "0.00".to_string(); + } + match granularity { + PriceGranularity::Low => { + let capped = cpm.min(5.0); + format!("{:.2}", (capped / 0.50).floor() * 0.50) + } + PriceGranularity::Medium => { + let capped = cpm.min(20.0); + format!("{:.2}", (capped / 0.10).floor() * 0.10) + } + PriceGranularity::High => { + let capped = cpm.min(20.0); + format!("{:.2}", (capped / 0.01).floor() * 0.01) + } + PriceGranularity::Dense | PriceGranularity::Auto => dense_bucket(cpm), + } + } + + fn dense_bucket(cpm: f64) -> String { + if cpm >= 20.0 { + return "20.00".to_string(); + } + if cpm >= 8.0 { + return format!("{:.2}", (cpm / 0.50).floor() * 0.50); + } + if cpm >= 3.0 { + return format!("{:.2}", (cpm / 0.05).floor() * 0.05); + } + format!("{:.2}", (cpm / 0.01).floor() * 0.01) + } + ``` + +- [ ] **Step 3: Export from lib.rs** + + ```rust + pub mod price_bucket; + ``` + +- [ ] **Step 4: Run tests** + + Run: `cargo test -p trusted-server-core price_bucket` + Expected: all tests pass + +- [ ] **Step 5: Commit** + + ```bash + git add crates/trusted-server-core/src/price_bucket.rs \ + crates/trusted-server-core/src/lib.rs + git commit -m "Add Prebid price granularity bucketing (dense default, auto = dense)" + ``` + +--- + +## Task 3: Extend `auction::types` — `MediaType::banner()` and `Bid::ad_id` + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` + +`CreativeOpportunityFormat` uses `#[serde(default = "MediaType::banner")]` which requires a free function. Add `ad_id: Option` to `Bid` for `hb_adid` targeting. The `suppress_nurl` config escape hatch is also added here to `PrebidConfig`. + +- [ ] **Step 1: Write failing tests** + + In `auction/types.rs` test module: + + ```rust + #[test] + fn media_type_banner_fn_returns_banner() { + assert_eq!(MediaType::banner(), MediaType::Banner); + } + + #[test] + fn bid_has_ad_id_field() { + let bid = Bid { + slot_id: "s".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("prebid-ad-id-abc".to_string()), + metadata: Default::default(), + }; + assert_eq!(bid.ad_id.as_deref(), Some("prebid-ad-id-abc")); + } + ``` + + Run: `cargo test -p trusted-server-core auction::types::tests` + Expected: compile error (`MediaType::banner` not found, `ad_id` field missing) + +- [ ] **Step 2: Add `MediaType::banner()` and `Bid::ad_id`** + + In `auction/types.rs`, add to `MediaType`: + + ```rust + impl MediaType { + pub fn banner() -> Self { + Self::Banner + } + } + ``` + + Add `ad_id: Option` field to `Bid`. Update the `make_bid` test helper to include `ad_id: None`. + +- [ ] **Step 3: Run tests** + + Run: `cargo test -p trusted-server-core auction` + Expected: all tests pass + +- [ ] **Step 4: Populate `ad_id` in prebid.rs** + + In `crates/trusted-server-core/src/integrations/prebid.rs`, in the `Bid` construction, find where `nurl` and `burl` are set and add: + + ```rust + ad_id: bid_obj.get("adid") + .or_else(|| bid_obj.get("id")) + .and_then(|v| v.as_str()) + .map(String::from), + ``` + + (Prebid Server uses lowercase `adid`. Fall back to `id` if absent.) + +- [ ] **Step 5: Add `suppress_nurl` to `PrebidConfig`** + + In `prebid.rs`, add to the `PrebidConfig` struct: + + ```rust + #[serde(default)] + pub suppress_nurl: bool, + ``` + + Write a test: + + ```rust + #[test] + fn prebid_config_suppress_nurl_defaults_to_false() { + let config = PrebidConfig::default(); + assert!(!config.suppress_nurl, "should not suppress nurl by default"); + } + ``` + + Run: `cargo test -p trusted-server-core integrations::prebid` + Expected: passes + +- [ ] **Step 6: Commit** + + ```bash + git add crates/trusted-server-core/src/auction/types.rs \ + crates/trusted-server-core/src/integrations/prebid.rs + git commit -m "Add MediaType::banner(), Bid::ad_id, and suppress_nurl config to PrebidConfig" + ``` + +--- + +## Task 4: `creative_opportunities.rs` — Config types and URL matching + +**Files:** + +- Create: `crates/trusted-server-core/src/creative_opportunities.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +- [ ] **Step 1: Write failing tests** + + Create `crates/trusted-server-core/src/creative_opportunities.rs` with only tests: + + ```rust + #[cfg(test)] + mod tests { + use super::*; + + fn make_slot(id: &str, patterns: Vec<&str>) -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: id.to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: patterns.into_iter().map(String::from).collect(), + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: crate::auction::types::MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + } + } + + #[test] + fn glob_matches_article_path() { + let slot = make_slot("atf", vec!["/20**"]); + assert!(slot.matches_path("/2024/01/my-article/"), "should match article path"); + assert!(!slot.matches_path("/"), "should not match root"); + } + + #[test] + fn exact_match_homepage() { + let slot = make_slot("home", vec!["/"]); + assert!(slot.matches_path("/"), "should match root"); + assert!(!slot.matches_path("/about"), "should not match /about"); + } + + #[test] + fn slot_id_validates_alphanumeric() { + assert!(validate_slot_id("atf_sidebar_ad").is_ok()); + assert!(validate_slot_id("below-content-0").is_ok()); + assert!(validate_slot_id("").is_err(), "empty id should fail"); + assert!(validate_slot_id("xss"#.to_string() + ), + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"Tcontent", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots at head-open"); + assert!(!html.contains("__ts_request_id"), "must NOT inject request_id — body-injection arch has no request_id"); + } + + #[test] + fn injects_ts_bids_before_body_close() { + let bids_script = r#""#; + let state = std::sync::Arc::new(std::sync::RwLock::new( + Some(bids_script.to_string()) + )); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!(html.contains("window.__ts_bids"), "should inject bids before "); + let bids_pos = html.find("window.__ts_bids").expect("bids should be in output"); + let body_close_pos = html.find("").expect(" should be in output"); + assert!(bids_pos < body_close_pos, "bids must appear before "); + } + + #[test] + fn injects_empty_ts_bids_when_state_is_none() { + let state = std::sync::Arc::new(std::sync::RwLock::new(None)); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!(html.contains("__ts_bids=JSON.parse(\"{}\""), "should inject empty bids on None state"); + } + ``` + + Run: `cargo test -p trusted-server-core html_processor` + Expected: compile error (no `ad_bids_state` field yet) + +- [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`** + + In `registry.rs`, add: + + ```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(), + }) + } + } + } + ``` + + (Adjust field names to match the actual `RegistryInner` struct.) + +- [ ] **Step 3: Update `HtmlProcessorConfig`** + + ```rust + pub struct HtmlProcessorConfig { + pub origin_host: String, + pub request_host: String, + pub request_scheme: String, + pub integrations: IntegrationRegistry, + /// Pre-computed ``. + /// Injected at `` open. `None` when no slots matched. + pub ad_slots_script: Option, + /// Shared auction result script — written by the auction task before HTML processing + /// begins. Handler reads this in `el.on_end_tag()` on the body element. + /// `None` means no auction ran (consent denied, bot UA, no slot match, etc.); + /// inject empty `__ts_bids = {}` as graceful fallback. + pub ad_bids_state: std::sync::Arc>>, + } + ``` + + Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_bids_state: Arc::new(RwLock::new(None))`. + +- [ ] **Step 4: Inject `ad_slots_script` at head-open** + + In `create_html_processor`, within the existing `element!("head", ...)` handler: + + ```rust + let ad_slots_script = config.ad_slots_script.clone(); + // existing captures... + + element!("head", |el| { + let mut snippet = String::new(); + if let Some(ref slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } + // existing integration head inserts... + if !snippet.is_empty() { + el.prepend(&snippet, ContentType::Html); + } + // DO NOT register on_end_tag — flushes immediately + Ok(()) + }) + ``` + +- [ ] **Step 5: Inject `__ts_bids` before `` via `el.on_end_tag()`** + + Add a new handler in `create_html_processor`. The shared state is already populated by the time lol_html reaches `` (Task 9 awaits the auction before starting HTML processing): + + ```rust + let ad_bids_state = config.ad_bids_state.clone(); + + element!("body", |el| { + let state = ad_bids_state.clone(); + el.on_end_tag(move |end_tag| { + let script = state.read().expect("should read bid state"); + let bids_script = match &*script { + Some(s) => s.clone(), + None => { + r#""#.to_string() + } + }; + end_tag.before(&bids_script, ContentType::Html); + Ok(()) + })?; + Ok(()) + }) + ``` + +- [ ] **Step 6: Run tests** + + Run: `cargo test -p trusted-server-core html_processor` + Expected: all tests pass + +- [ ] **Step 7: Run full suite** + + Run: `cargo test --workspace` + Expected: clean + +- [ ] **Step 8: Commit** + + ```bash + git add crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/integrations/registry.rs + git commit -m "Inject __ts_ad_slots at head-open and __ts_bids before via shared auction state" + ``` + +--- + +## Task 8: `handle_publisher_request` async restructuring + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +> **Key constraint from spec §4.3 and §3:** No `bid_cache`. No `/ts-bids`. No `request_id`. Bids travel inline with the HTML response via body injection. The `Arc>>` is the coordination mechanism within a single request's lifetime — it is written before HTML processing and read by the lol_html `` handler. + +> **Eligibility gating (spec §4.3):** Auctions fire only for real GET requests from non-bot, non-prefetch clients with TCF Purpose 1 consent and at least one matching slot. All other requests proceed with no auction and no `__ts_bids` injection. + +> **Cache-Control (spec §4.7):** Set `Cache-Control: private, max-age=0` (not `no-store`) to preserve BFCache eligibility. Strip `Surrogate-Control` and `Fastly-Surrogate-Control`. + +- [ ] **Step 1: Update function signature** + + Change `handle_publisher_request` in `publisher.rs`: + + > **Existing context:** The existing `publisher.rs` function body already computes `consent_context`, `ec_id`, `request_info`, `origin_host`, and `backend_name` before the origin fetch. Steps below insert new logic between those existing computations and the origin fetch — they do not replace them. + + ```rust + pub async fn handle_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + services: &RuntimeServices, + orchestrator: &crate::auction::orchestrator::AuctionOrchestrator, + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + mut req: Request, + ) -> Result> + ``` + + Add imports at top of file: + + ```rust + use std::sync::{Arc, RwLock}; + use fastly::http::header; + use crate::auction::orchestrator::AuctionOrchestrator; + use crate::auction::types::{AuctionContext, AuctionRequest, PublisherInfo, UserInfo, SiteInfo}; + use crate::creative_opportunities::{CreativeOpportunitiesFile, match_slots}; + use crate::price_bucket::price_bucket; + ``` + + > **`send_async` return type:** `req.send_async()` returns `fastly::handle::PendingRequestHandle` (re-exported as `fastly::PendingRequest` in recent versions). Confirm the exact type from the `fastly` crate version in `Cargo.toml`; `.wait()` is the blocking resolve method on whichever type is returned. + +- [ ] **Step 2: Apply auction-eligibility gates** + + At the top of the function body, before origin fetch: + + ```rust + let request_path = req.get_path().to_string(); + let request_method = req.get_method().clone(); + + // Gate 1: Only GET triggers auctions. HEAD skips everything. + let is_get = request_method == fastly::http::Method::GET; + + // Gate 2: Skip prefetch hints (Sec-Purpose: prefetch or Purpose: prefetch). + let is_prefetch = req.get_header_str("sec-purpose") + .map_or(false, |v| v.contains("prefetch")) + || req.get_header_str("purpose") + .map_or(false, |v| v.contains("prefetch")); + + // Gate 3: Skip well-known crawler UAs (protects SSP QPS budget). + let user_agent = req.get_header_str("user-agent").unwrap_or(""); + let is_bot = ["Googlebot", "Bingbot", "AhrefsBot", "SemrushBot", "DotBot"] + .iter() + .any(|bot| user_agent.contains(bot)); + + // Gate 4: Slot match. + let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { + match_slots(&slots_file.slots, &request_path) + .into_iter() + .cloned() + .collect() + } else { + Vec::new() + }; + + // Gate 5: TCF Purpose 1 consent. + let consent_allows_auction = consent_context + .tcf + .as_ref() + .map_or(false, |tcf| tcf.has_purpose_consent(1)); + + let should_run_auction = is_get + && !is_prefetch + && !is_bot + && !matched_slots.is_empty() + && consent_allows_auction; + + let auction_timeout_ms = settings + .creative_opportunities + .as_ref() + .and_then(|co| co.auction_timeout_ms) + .unwrap_or(settings.auction.timeout_ms); + ``` + +- [ ] **Step 3: Create shared bid state, fire origin + auction concurrently** + + ```rust + // Shared state: auction task writes the ready-to-inject script; lol_html + // handler reads it. Both within the same request — no cross-request sharing. + let ad_bids_state: Arc>> = Arc::new(RwLock::new(None)); + + restrict_accept_encoding(&mut req); + req.set_header("host", &origin_host); + + // Fire origin immediately — both origin and auction SSP calls overlap on the network. + let pending_origin = req + .send_async(&backend_name) + .change_context(TrustedServerError::Proxy { + message: "Failed to dispatch async origin request".to_string(), + })?; + + // Run auction. Internal SSP calls use send_async and overlap with origin fetch. + let auction_result = if should_run_auction { + let co_config = settings.creative_opportunities.as_ref() + .expect("should be present when should_run_auction is true"); + 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 result to shared state before HTML processing begins. + // The lol_html handler reads this synchronously — it is always populated here. + // `build_bid_map` returns `serde_json::Map`. + if should_run_auction { + let co_config = settings.creative_opportunities.as_ref() + .expect("should be present"); + let empty_bids: std::collections::HashMap = + std::collections::HashMap::new(); + let winning_bids = auction_result.as_ref() + .map(|r| &r.winning_bids) + .unwrap_or(&empty_bids); + let bid_map = build_bid_map(winning_bids, co_config.price_granularity); + let bids_script = build_bids_script(&bid_map); + *ad_bids_state.write().expect("should write bid state") = Some(bids_script); + } + + // Await origin response (may already be buffered since we started it before the auction). + let mut response = pending_origin + .wait() + .change_context(TrustedServerError::Proxy { + message: "Failed to await origin response".to_string(), + })?; + ``` + +- [ ] **Step 4: Build head injection script, set cache headers, force chunked encoding** + + After acquiring `response`: + + ```rust + let ad_slots_script = if let Some(co_config) = &settings.creative_opportunities { + if !matched_slots.is_empty() { + Some(build_ad_slots_script(&matched_slots, co_config)) + } else { + None + } + } else { + None + }; + + // Set cache headers when slots matched. private, max-age=0 (not no-store) preserves + // BFCache eligibility — browser back/forward cache restores the already-rendered ad + // without firing a new GAM call, which is the desired behavior. + if ad_slots_script.is_some() { + response.set_header(header::CACHE_CONTROL, "private, max-age=0"); + response.remove_header("surrogate-control"); + response.remove_header("fastly-surrogate-control"); + } + + // Force chunked encoding so reaches the browser immediately as chunks arrive. + // Sending both Content-Length and Transfer-Encoding is invalid HTTP/1.1. + response.remove_header(header::CONTENT_LENGTH); + response.set_header("transfer-encoding", "chunked"); + ``` + +- [ ] **Step 5: Thread shared state into `OwnedProcessResponseParams`** + + Update `OwnedProcessResponseParams`: + + ```rust + pub struct OwnedProcessResponseParams { + // existing fields... + pub(crate) ad_slots_script: Option, + pub(crate) ad_bids_state: Arc>>, + } + ``` + + Pass both through to `create_html_stream_processor` and into `HtmlProcessorConfig`. + +- [ ] **Step 6: Add `pub(crate)` helper functions** + + > **`BidMap` type:** Use `serde_json::Map` directly — no separate module needed. + + Add helpers in this order (each function is used by the one below it, so define leaf functions first): + + ```rust + /// HTML-escape a JSON string for safe inline `"#) + } + + /// Build the `"#) + } + + fn build_auction_request( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + ec_id: &str, + consent_context: &crate::consent::ConsentContext, + request_info: &crate::http_util::RequestInfo, + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + ) -> AuctionRequest { + AuctionRequest { + id: uuid::Uuid::new_v4().to_string(), + slots: matched_slots.iter() + .map(|s| s.to_ad_slot(&co_config.gam_network_id)) + .collect(), + publisher: PublisherInfo { + domain: request_info.host.clone(), + page_url: Some(format!("{}://{}", request_info.scheme, request_info.host)), + }, + user: UserInfo { + id: ec_id.to_string(), + fresh_id: uuid::Uuid::new_v4().to_string(), + consent: Some(consent_context.clone()), + }, + device: None, + site: Some(SiteInfo { + domain: request_info.host.clone(), + page: request_info.host.clone(), + }), + context: Default::default(), + } + } + ``` + + > **Type note:** All helper signatures use `serde_json::Map` directly. Do not create a `BidMap` type alias or `bid_types.rs` module. + +- [ ] **Step 7: Update `main.rs` call site** + + In `crates/trusted-server-adapter-fastly/src/main.rs`: + + ```rust + // At startup (top of main() / request handler setup, before the request dispatch loop). + // include_str! embeds the file at compile time — no runtime file I/O. + const CREATIVE_OPPORTUNITIES_TOML: &str = + include_str!("../../../creative-opportunities.toml"); + + let slots_file: trusted_server_core::creative_opportunities::CreativeOpportunitiesFile = + toml::from_str(CREATIVE_OPPORTUNITIES_TOML) + .expect("should parse creative-opportunities.toml"); + ``` + + `slots_file` is a local in the startup/handler scope and passed by reference into `handle_publisher_request` on each request — no `Arc` needed since it's immutable and the handler borrows it. + + Update the call to `handle_publisher_request`: + + ```rust + match handle_publisher_request( + settings, + integration_registry, + &publisher_services, + orchestrator, // existing + &slots_file, // new + req, + ).await { + // existing match arms unchanged + } + ``` + + There is **no `/ts-bids` route** to add. The body injection is complete within `handle_publisher_request`. + +- [ ] **Step 8: Compile check** + + Run: `cargo check --workspace` + Expected: clean compile + +- [ ] **Step 9: Run full tests** + + Run: `cargo test --workspace` + Expected: all pass + +- [ ] **Step 10: Commit** + + ```bash + git add crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-adapter-fastly/src/main.rs + git commit -m "Convert handle_publisher_request to async; body-inject __ts_bids; eligibility gates; max-age=0" + ``` + +--- + +## Task 9: GPT head injector — emit `__tsAdInit` with synchronous bid read + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` + +> **Critical:** `__tsAdInit` reads `window.__ts_bids` **synchronously** — no fetch, no Promise. `window.__ts_bids` is already on the page (injected before ``) when `__tsAdInit` runs (it executes post-DCL, after `` is received). Both `nurl` and `burl` fire client-side from `slotRenderEnded`; neither is fired server-side. + +- [ ] **Step 1: Write failing test** + + ```rust + #[test] + fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { + let config = test_config(); + let integration = GptIntegration::new(config); + let ctx = make_test_context(); + let inserts = integration.head_inserts(&ctx); + let combined = inserts.join(""); + assert!(combined.contains("__tsAdInit"), "should define __tsAdInit"); + assert!(combined.contains("window.__ts_bids"), "should read window.__ts_bids synchronously"); + assert!(combined.contains("ts_initial"), "should set ts_initial sentinel"); + assert!(combined.contains("slotRenderEnded"), "should register slotRenderEnded"); + assert!(combined.contains("sendBeacon"), "should fire nurl and burl via sendBeacon"); + assert!(combined.contains("nurl"), "should fire nurl on confirmed render"); + assert!(!combined.contains("/ts-bids"), "must NOT fetch /ts-bids — bids are inline on the page"); + assert!(!combined.contains("bidsPromise"), "must NOT use bidsPromise — bids are synchronous"); + assert!(!combined.contains("__ts_request_id"), "must NOT reference request_id — no longer used"); + } + ``` + + Run: `cargo test -p trusted-server-core integrations::gpt` + Expected: FAIL + +- [ ] **Step 2: Replace `head_inserts()` in gpt.rs** + + ```rust + impl IntegrationHeadInjector for GptIntegration { + fn integration_id(&self) -> &'static str { + GPT_INTEGRATION_ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + vec![ + "" + .to_string(), + // __tsAdInit: reads window.__ts_bids synchronously (injected before ). + // No fetch, no Promise. Executes post-DCL when has already arrived. + // Both nurl and burl fire client-side from slotRenderEnded — never server-side. + // Note: window.__tsjs_installGptShim above is an EXISTING function in the + // tsjs-core bundle that stubs googletag.cmd before the real GPT loads. + concat!( + "" + ).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 with synchronous window.__ts_bids read; nurl+burl from slotRenderEnded" + ``` + +--- + +## Task 10: `gpt/index.ts` — TypeScript `__tsAdInit` with slim-Prebid lazy loader + +**Files:** + +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` + +The TypeScript version mirrors the Rust inline string from Task 9 and adds the lazy slim-Prebid loader. Slim-Prebid loads post-`window.load` and handles two things: refresh auctions (via existing GPT refresh triggers) and userID module warm-up to enrich the EC graph for the next request. + +- [ ] **Step 1: Write failing tests** + + 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('reads window.__ts_bids synchronously and applies bid targeting before refresh', async () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue(['abc']), + } + 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_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + } + + const fetchSpy = vi.spyOn(global, 'fetch') + + const { installTsAdInit } = await import('./index') + installTsAdInit() + ;(window as any).__tsAdInit() + + expect(fetchSpy).not.toHaveBeenCalled() + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00') + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo') + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1') + expect(mockPubads.refresh).toHaveBeenCalled() + + fetchSpy.mockRestore() + }) + + it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) + let capturedListener: ((e: any) => void) | undefined + + const 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_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + } + + const { installTsAdInit } = await import('./index') + installTsAdInit() + ;(window as any).__tsAdInit() + + expect(capturedListener).toBeDefined() + capturedListener!({ isEmpty: false, slot: mockSlot }) + + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/win') + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill') + beaconSpy.mockRestore() + }) + + it('does not fire nurl/burl when bid did not win GAM line item', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) + let capturedListener: ((e: any) => void) | undefined + + const mockSlotNoMatch = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), + } + const mockPubads = { + enableSingleRequest: vi.fn(), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: any) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn + }), + } + ;(window as any).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + } + ;(window as any).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: {}, + }, + ] + ;(window as any).__ts_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + } + + const { installTsAdInit } = await import('./index') + installTsAdInit() + ;(window as any).__tsAdInit() + capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }) + + expect(beaconSpy).not.toHaveBeenCalled() + beaconSpy.mockRestore() + }) + + it('calls refresh even when __ts_bids is empty (graceful fallback)', () => { + const mockPubads = { + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + } + ;(window as any).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + }), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + } + ;(window as any).__ts_ad_slots = [] + ;(window as any).__ts_bids = {} + + const { installTsAdInit } = require('./index') + installTsAdInit() + ;(window as any).__tsAdInit() + + expect(mockPubads.refresh).toHaveBeenCalled() + }) + }) + ``` + + Run: `cd crates/js/lib && npx vitest run` + Expected: FAIL — `installTsAdInit` not defined or assertions fail + +- [ ] **Step 2: Implement `installTsAdInit` in `index.ts`** + + Replace the old `/ts-bids` fetch implementation with: + + ```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 + nurl?: string + burl?: string + } + + type TsWindow = Window & { + __ts_ad_slots?: TsAdSlot[] + __ts_bids?: Record + __tsAdInit?: () => void + } + + /** + * Install `window.__tsAdInit`. + * + * Reads `window.__ts_ad_slots` (injected at head-open) and `window.__ts_bids` + * (injected before ) synchronously — no fetch, no Promise. Applies bid + * targeting to GPT slots, sets the `ts_initial` sentinel, registers + * `slotRenderEnded` to fire both nurl and burl via sendBeacon when our + * specific Prebid bid wins the GAM line item match, then calls refresh(). + */ + export function installTsAdInit(): void { + const w = window as TsWindow + w.__tsAdInit = function () { + const slots = w.__ts_ad_slots ?? [] + const bids = w.__ts_bids ?? {} + 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) + ) + const bid = bids[slot.id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + gptSlot.setTargeting('ts_initial', '1') + return { id: slot.id, gptSlot } + }) + .filter(Boolean) as Array<{ + 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] ?? {} + const ourBidWon = + !event.isEmpty && + bid.hb_adid && + event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + if (ourBidWon) { + if (bid.nurl) navigator.sendBeacon(bid.nurl) + if (bid.burl) navigator.sendBeacon(bid.burl) + } + }) + + g.pubads().refresh() + }) + } + } + ``` + +- [ ] **Step 3: Add lazy slim-Prebid loader (post-`window.load`)** + + After `installTsAdInit`, add: + + ```typescript + /** + * Register the slim-Prebid lazy loader. Fires after window.load — off the + * critical path. slim-Prebid handles refresh auctions and userID module + * warm-up (ID5, sharedID, LiveRamp ATS, Lockr). It skips initial-render slots + * (ts_initial=1) and registers as the GPT refresh handler for scroll/sticky auctions. + * + * Phase 1: no-op unless window.__tsjs_slim_prebid_url is set (it won't be until + * the slim-Prebid bundle build target ships in a later phase). + */ + export function installSlimPrebidLoader(): void { + const url = (window as any).__tsjs_slim_prebid_url as string | undefined + if (!url) return + window.addEventListener('load', () => { + const script = document.createElement('script') + script.src = url + script.defer = true + document.head.appendChild(script) + }) + } + ``` + + Call `installTsAdInit()` from the integration's existing initialization path — wherever the module's init function runs at page load (look for the existing `init()` or module-level call that sets up the GPT integration). Add: + + ```typescript + // In the integration's init / module entry point: + installTsAdInit() + ``` + + `window.__tsAdInit()` itself is called by `__tsAdInit` being invoked from the `"); + assert!(!inner.contains('<'), "no unescaped < in script content"); + assert!(!inner.contains('>'), "no unescaped > in script content"); + } + + #[test] + fn bid_map_includes_nurl_and_burl() { + let mut winning_bids = HashMap::new(); + winning_bids.insert("atf_sidebar_ad".to_string(), 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: Some("https://ssp/win".to_string()), + burl: Some("https://ssp/bill".to_string()), + ad_id: Some("abc123".to_string()), + metadata: Default::default(), + }); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); + assert_eq!(entry.get("hb_pb").and_then(|v| v.as_str()), Some("2.50")); + assert_eq!(entry.get("hb_bidder").and_then(|v| v.as_str()), Some("kargo")); + assert_eq!(entry.get("hb_adid").and_then(|v| v.as_str()), Some("abc123")); + assert_eq!(entry.get("nurl").and_then(|v| v.as_str()), Some("https://ssp/win")); + assert_eq!(entry.get("burl").and_then(|v| v.as_str()), Some("https://ssp/bill")); + } + + #[test] + fn bid_map_excludes_slot_when_price_is_none() { + let mut winning_bids = HashMap::new(); + winning_bids.insert("no-price-slot".to_string(), Bid { + slot_id: "no-price-slot".to_string(), + price: None, + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + metadata: Default::default(), + }); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + assert!(map.is_empty(), "slot with no price should be excluded from bid map"); + } + + #[test] + fn bids_script_is_xss_safe() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + let script = build_bids_script(&map); + let inner = script + .trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in bids script"); + assert!(!inner.contains('>'), "no unescaped > in bids script"); + } + + #[test] + fn html_escape_encodes_special_chars() { + assert_eq!(html_escape_for_script(", ContentType::Html)`. + +> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped +> before insertion into the `