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.ts b/crates/js/lib/src/integrations/gpt/index.ts index 0b9e30235..f6563318f 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -31,12 +31,20 @@ interface GoogleTagSlot { getAdUnitPath(): string; getSlotElementId(): string; setTargeting(key: string, value: string | string[]): GoogleTagSlot; + getTargeting(key: string): string[]; + addService(service: GoogleTagPubAdsService): GoogleTagSlot; } interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; + disableInitialLoad(): void; + addEventListener( + eventName: 'slotRenderEnded', + callback: (event: SlotRenderEndedEvent) => void + ): void; + refresh(slots?: GoogleTagSlot[]): void; } interface GoogleTag { @@ -54,6 +62,38 @@ interface GoogleTag { type GptWindow = Window & { googletag?: Partial; + __ts_ad_slots?: TsAdSlot[]; + __ts_request_id?: string; + __tsAdInit?: () => boolean; + __tsAdInitInstalled?: boolean; +}; + +type TsAdSlot = { + id?: string; + gam_unit_path?: string; + div_id?: string; + formats?: Array; + targeting?: Record; +}; + +type TsBidTargeting = { + hb_pb?: string; + hb_bidder?: string; + hb_adid?: string; + burl?: string; +}; + +type TsBidMap = Record; + +type DefinedTsSlot = { + descriptor: TsAdSlot; + slot: GoogleTagSlot; +}; + +type SlotRenderEndedEvent = { + slot?: { + getTargeting?: (key: string) => string[]; + }; }; // ------------------------------------------------------------------ @@ -137,6 +177,155 @@ function patchCommandQueue(tag: Partial): void { log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); } +function readTsAdSlots(win: GptWindow): TsAdSlot[] { + return Array.isArray(win.__ts_ad_slots) ? win.__ts_ad_slots : []; +} + +function fetchTsBids(win: GptWindow): Promise { + const rid = win.__ts_request_id; + if (!rid || typeof fetch !== 'function') { + return Promise.resolve({}); + } + + return fetch(`/ts-bids?rid=${encodeURIComponent(rid)}`, { credentials: 'omit' }) + .then((response) => response.json() as Promise) + .catch(() => ({})); +} + +function applyStaticTargeting(slot: GoogleTagSlot, targeting: TsAdSlot['targeting']): void { + for (const [key, value] of Object.entries(targeting ?? {})) { + slot.setTargeting(key, value); + } +} + +function applyBidTargeting(slot: GoogleTagSlot, bid: TsBidTargeting): void { + for (const key of ['hb_pb', 'hb_bidder', 'hb_adid'] as const) { + const value = bid[key]; + if (value != null) { + slot.setTargeting(key, String(value)); + } + } +} + +function installBurlListener( + pubads: GoogleTagPubAdsService, + bidsByAdId: Map +): void { + if (typeof pubads.addEventListener !== 'function') { + return; + } + + pubads.addEventListener('slotRenderEnded', (event) => { + const hbAdIds = event.slot?.getTargeting?.('hb_adid') ?? []; + const hbAdId = hbAdIds[0]; + const bid = hbAdId ? bidsByAdId.get(hbAdId) : undefined; + + if ( + !bid?.burl || + typeof navigator === 'undefined' || + typeof navigator.sendBeacon !== 'function' + ) { + return; + } + + navigator.sendBeacon(bid.burl); + bidsByAdId.delete(hbAdId); + }); +} + +function runTsAdInit(win: GptWindow): void { + const tag = win.googletag as GoogleTag | undefined; + const bidsPromise = fetchTsBids(win); + const slots = readTsAdSlots(win); + const definedSlots: DefinedTsSlot[] = []; + const bidsByAdId = new Map(); + + if ( + !tag || + typeof tag.defineSlot !== 'function' || + typeof tag.pubads !== 'function' || + typeof tag.enableServices !== 'function' + ) { + return; + } + + const pubads = tag.pubads(); + installBurlListener(pubads, bidsByAdId); + + for (const descriptor of slots) { + if (!descriptor.gam_unit_path || !descriptor.div_id || !descriptor.id) { + continue; + } + + const slot = tag.defineSlot( + descriptor.gam_unit_path, + descriptor.formats ?? [], + descriptor.div_id + ); + if (!slot) { + continue; + } + + if (typeof slot.addService === 'function') { + slot.addService(pubads); + } + applyStaticTargeting(slot, descriptor.targeting); + definedSlots.push({ descriptor, slot }); + } + + if (typeof pubads.disableInitialLoad === 'function') { + pubads.disableInitialLoad(); + } + tag.enableServices(); + + for (const { descriptor } of definedSlots) { + if (typeof tag.display === 'function') { + tag.display(descriptor.div_id as string); + } + } + + bidsPromise.then((bids) => { + for (const { descriptor, slot } of definedSlots) { + const bid = bids[descriptor.id as string]; + if (!bid) { + continue; + } + + applyBidTargeting(slot, bid); + if (bid.hb_adid) { + bidsByAdId.set(String(bid.hb_adid), bid); + } + } + + if (typeof pubads.refresh === 'function') { + pubads.refresh(definedSlots.map(({ slot }) => slot)); + } + }); +} + +/** + * Install the Trusted Server ad bootstrap for GPT slots. + * + * The bootstrap reads `window.__ts_ad_slots` and `window.__ts_request_id`, + * defines GPT slots immediately, then applies server-side bid targeting from + * `/ts-bids` before refreshing the slots. + */ +export function installTsAdInit(): boolean { + if (typeof window === 'undefined') { + return false; + } + + const win = window as GptWindow; + if (win.__tsAdInitInstalled) { + return true; + } + + win.__tsAdInitInstalled = true; + const tag = ensureGoogleTagStub(win); + tag.cmd!.push(() => runTsAdInit(win)); + return true; +} + /** * Install the GPT integration shim. * @@ -156,6 +345,7 @@ export function installGptShim(): boolean { const tag = ensureGoogleTagStub(win); patchCommandQueue(tag); + installTsAdInit(); log.info('GPT shim installed'); return true; @@ -170,9 +360,10 @@ export function installGptShim(): boolean { // regardless of script order, the module also checks for a pre-set enable flag // immediately after registering the function. if (typeof window !== 'undefined') { - const win = window as Record; + const win = window as unknown as Record; win.__tsjs_installGptShim = installGptShim; + win.__tsAdInit = installTsAdInit; if (win.__tsjs_gpt_enabled === true) { installGptShim(); diff --git a/crates/js/lib/test/integrations/gpt/index.test.ts b/crates/js/lib/test/integrations/gpt/index.test.ts index 57c4015dc..8ba761351 100644 --- a/crates/js/lib/test/integrations/gpt/index.test.ts +++ b/crates/js/lib/test/integrations/gpt/index.test.ts @@ -17,6 +17,94 @@ type GptWindow = Window & { }; }; +type TsAdInitWindow = Window & { + __ts_ad_slots?: Array>; + __ts_request_id?: string; + googletag?: { + cmd: Array<() => void>; + defineSlot: ReturnType; + pubads: ReturnType; + enableServices: ReturnType; + display: ReturnType; + }; +}; + +type MockSlot = { + id: string; + targeting: Map; + setTargeting: ReturnType; + getTargeting: ReturnType; + addService: ReturnType; +}; + +const flushPromises = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +function jsonResponse(body: unknown) { + return { + json: () => Promise.resolve(body), + }; +} + +function createGptHarness(win: TsAdInitWindow) { + const operations: string[] = []; + const slots: MockSlot[] = []; + let slotRenderEnded: ((event: { slot: MockSlot }) => void) | undefined; + + const pubadsService = { + addEventListener: vi.fn((eventName: string, callback: (event: { slot: MockSlot }) => void) => { + if (eventName === 'slotRenderEnded') { + slotRenderEnded = callback; + } + }), + disableInitialLoad: vi.fn(() => { + operations.push('disableInitialLoad'); + }), + refresh: vi.fn(() => { + operations.push('refresh'); + }), + }; + + win.googletag = { + cmd: [], + defineSlot: vi.fn((_adUnitPath: string, _sizes: unknown, elementId: string) => { + const targeting = new Map(); + const slot: MockSlot = { + id: elementId, + targeting, + setTargeting: vi.fn((key: string, value: string | string[]) => { + const values = Array.isArray(value) ? value : [value]; + targeting.set(key, values); + operations.push(`set:${elementId}:${key}:${values.join(',')}`); + return slot; + }), + getTargeting: vi.fn((key: string) => targeting.get(key) ?? []), + addService: vi.fn(() => slot), + }; + slots.push(slot); + operations.push(`define:${elementId}`); + return slot; + }), + pubads: vi.fn(() => pubadsService), + enableServices: vi.fn(() => { + operations.push('enableServices'); + }), + display: vi.fn((elementId: string) => { + operations.push(`display:${elementId}`); + }), + }; + + return { + operations, + pubadsService, + slots, + triggerSlotRenderEnded: (slot: MockSlot) => slotRenderEnded?.({ slot }), + }; +} + describe('GPT shim – patchCommandQueue', () => { let win: GptWindow; let installGptShim: () => boolean; @@ -165,6 +253,203 @@ describe('GPT shim – patchCommandQueue', () => { }); }); +describe('GPT shim – __tsAdInit bootstrap', () => { + let win: TsAdInitWindow; + let installTsAdInit: () => boolean; + let originalFetch: typeof globalThis.fetch; + let originalSendBeacon: typeof navigator.sendBeacon; + + beforeEach(async () => { + vi.resetModules(); + win = window as TsAdInitWindow; + delete win.__ts_ad_slots; + delete win.__ts_request_id; + delete (win as TsAdInitWindow & { __tsAdInit?: () => boolean }).__tsAdInit; + delete (win as TsAdInitWindow & { __tsAdInitInstalled?: boolean }).__tsAdInitInstalled; + delete win.googletag; + originalFetch = globalThis.fetch; + originalSendBeacon = navigator.sendBeacon; + + const mod = await import('../../../src/integrations/gpt/index'); + installTsAdInit = mod.installTsAdInit; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + navigator.sendBeacon = originalSendBeacon; + delete win.__ts_ad_slots; + delete win.__ts_request_id; + delete (win as TsAdInitWindow & { __tsAdInit?: () => boolean }).__tsAdInit; + delete (win as TsAdInitWindow & { __tsAdInitInstalled?: boolean }).__tsAdInitInstalled; + delete win.googletag; + }); + + it('fetches request scoped bids without credentials', async () => { + win.__ts_ad_slots = []; + win.__ts_request_id = 'request id/1'; + globalThis.fetch = vi.fn(() => Promise.resolve(jsonResponse({}))) as unknown as typeof fetch; + createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(globalThis.fetch).toHaveBeenCalledWith('/ts-bids?rid=request%20id%2F1', { + credentials: 'omit', + }); + }); + + it('applies static slot targeting before refresh', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => Promise.resolve(jsonResponse({}))) as unknown as typeof fetch; + const { operations } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(operations.indexOf('set:div-atf-sidebar:pos:atf')).toBeGreaterThanOrEqual(0); + expect(operations.indexOf('set:div-atf-sidebar:pos:atf')).toBeLessThan( + operations.indexOf('refresh') + ); + }); + + it('applies hb targeting before refresh', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => + Promise.resolve( + jsonResponse({ + atf_sidebar: { + hb_pb: '1.20', + hb_bidder: 'rubicon', + hb_adid: 'ad-123', + }, + }) + ) + ) as unknown as typeof fetch; + const { operations } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + for (const field of ['hb_pb', 'hb_bidder', 'hb_adid']) { + const operation = operations.find((entry) => + entry.startsWith(`set:div-atf-sidebar:${field}:`) + ); + expect(operation).toBeDefined(); + expect(operations.indexOf(operation!)).toBeLessThan(operations.indexOf('refresh')); + } + }); + + it('disables initial load before display so GPT waits for refresh', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => Promise.resolve(jsonResponse({}))) as unknown as typeof fetch; + const { operations } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(operations.indexOf('disableInitialLoad')).toBeGreaterThanOrEqual(0); + expect(operations.indexOf('disableInitialLoad')).toBeLessThan( + operations.indexOf('display:div-atf-sidebar') + ); + expect(operations.indexOf('display:div-atf-sidebar')).toBeLessThan( + operations.indexOf('refresh') + ); + }); + + it('refreshes GPT slots when bid fetch fails', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => + Promise.reject(new Error('network down')) + ) as unknown as typeof fetch; + const { pubadsService } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + expect(pubadsService.refresh).toHaveBeenCalledTimes(1); + }); + + it('fires burl only after rendered slot targeting matches bid hb_adid', async () => { + win.__ts_ad_slots = [ + { + id: 'atf_sidebar', + gam_unit_path: '/21765378893/atf_sidebar', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ]; + win.__ts_request_id = 'rid-123'; + globalThis.fetch = vi.fn(() => + Promise.resolve( + jsonResponse({ + atf_sidebar: { + hb_pb: '1.20', + hb_bidder: 'rubicon', + hb_adid: 'ad-123', + burl: 'https://bidder.example/bill', + }, + }) + ) + ) as unknown as typeof fetch; + navigator.sendBeacon = vi.fn(() => true); + const { slots, triggerSlotRenderEnded } = createGptHarness(win); + + installTsAdInit(); + win.googletag!.cmd[0](); + await flushPromises(); + + slots[0].targeting.set('hb_adid', ['other-ad']); + triggerSlotRenderEnded(slots[0]); + expect(navigator.sendBeacon).not.toHaveBeenCalled(); + + slots[0].targeting.set('hb_adid', ['ad-123']); + triggerSlotRenderEnded(slots[0]); + expect(navigator.sendBeacon).toHaveBeenCalledWith('https://bidder.example/bill'); + }); +}); + describe('GPT shim – runtime gating', () => { type GatedWindow = Window & { __tsjs_gpt_enabled?: boolean; diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index e483ea621..a730efcd6 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -20,6 +20,7 @@ log = { workspace = true } log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } trusted-server-core = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs new file mode 100644 index 000000000..ab8d04e0c --- /dev/null +++ b/crates/trusted-server-adapter-fastly/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=../../creative-opportunities.toml"); +} diff --git a/crates/trusted-server-adapter-fastly/src/bid_cache.rs b/crates/trusted-server-adapter-fastly/src/bid_cache.rs new file mode 100644 index 000000000..aa9e4cbdc --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/bid_cache.rs @@ -0,0 +1,188 @@ +//! Fastly Core Cache-backed bid cache rendezvous. + +use error_stack::{Report, ResultExt}; +use fastly::cache::core::{self, CacheKey}; +use std::io::{Read as _, Write as _}; +use std::time::Duration; +use trusted_server_core::bid_cache::{ + AuctionDeadline, BidCache, BidCacheEntry, BidCacheError, BidCacheResult, BidMap, CacheResult, +}; + +const DEFAULT_BID_CACHE_TTL: Duration = Duration::from_secs(30); +// Conservative upper bound for reconstructed deadlines; keeps /ts-bids from +// long-polling up to DEFAULT_BID_CACHE_TTL (30 s) when the auction already +// finished. Auction timeouts are typically 200–500 ms. +const DEFAULT_MAX_RECONSTRUCTED_WAIT: Duration = Duration::from_millis(800); + +/// Fastly Core Cache-backed [`BidCache`] implementation. +pub struct FastlyBidCache { + ttl: Duration, + max_reconstructed_wait: Duration, +} + +impl FastlyBidCache { + /// Creates a Fastly bid cache with the default short TTL. + pub fn new() -> Self { + Self { + ttl: DEFAULT_BID_CACHE_TTL, + max_reconstructed_wait: DEFAULT_MAX_RECONSTRUCTED_WAIT, + } + } + + /// Creates a Fastly bid cache with an explicit TTL. + #[cfg(test)] + pub fn with_ttl(ttl: Duration) -> Self { + Self { + ttl, + max_reconstructed_wait: ttl, + } + } + + /// Creates a Fastly bid cache with explicit cache and deadline bounds. + #[cfg(test)] + pub fn with_limits(ttl: Duration, max_reconstructed_wait: Duration) -> Self { + Self { + ttl, + max_reconstructed_wait, + } + } + + /// Returns the Core Cache key for a bid request ID. + pub fn cache_key_for_request_id(request_id: &str) -> String { + format!("ts-bids:{request_id}") + } + + fn cache_key(request_id: &str) -> CacheKey { + CacheKey::from(Self::cache_key_for_request_id(request_id)) + } + + fn write_entry(&self, request_id: &str, entry: &BidCacheEntry) -> BidCacheResult<()> { + let payload = serde_json::to_vec(entry).change_context(BidCacheError::Serialize)?; + + let mut writer = core::insert(Self::cache_key(request_id), self.ttl) + .sensitive_data(true) + .known_length(payload.len() as u64) + .execute() + .map_err(|error| { + Report::new(BidCacheError::PlatformCache) + .attach(format!("failed to start Core Cache insert: {error}")) + })?; + + writer + .write_all(&payload) + .change_context(BidCacheError::Io)?; + writer.finish().change_context(BidCacheError::Io)?; + + Ok(()) + } +} + +impl Default for FastlyBidCache { + fn default() -> Self { + Self::new() + } +} + +impl BidCache for FastlyBidCache { + fn mark_pending( + &self, + request_id: &str, + auction_deadline: AuctionDeadline, + ) -> BidCacheResult<()> { + self.write_entry( + request_id, + &BidCacheEntry::Pending { + auction_deadline_epoch_ms: auction_deadline.epoch_ms, + }, + ) + } + + fn put(&self, request_id: &str, bids: BidMap) -> BidCacheResult<()> { + self.write_entry(request_id, &BidCacheEntry::Complete { bids }) + } + + fn try_get(&self, request_id: &str) -> BidCacheResult { + let Some(found) = core::lookup(Self::cache_key(request_id)) + .execute() + .map_err(|error| { + Report::new(BidCacheError::PlatformCache) + .attach(format!("failed to lookup Core Cache object: {error}")) + })? + else { + return Ok(CacheResult::NotFound); + }; + + let mut payload = Vec::new(); + found + .to_stream() + .map_err(|error| { + Report::new(BidCacheError::PlatformCache) + .attach(format!("failed to read Core Cache body: {error}")) + })? + .read_to_end(&mut payload) + .change_context(BidCacheError::Io)?; + + match serde_json::from_slice(&payload).change_context(BidCacheError::Deserialize)? { + BidCacheEntry::Pending { + auction_deadline_epoch_ms, + } => { + let Some(auction_deadline) = AuctionDeadline::from_epoch_ms_with_max_remaining( + auction_deadline_epoch_ms, + self.max_reconstructed_wait, + ) else { + return Ok(CacheResult::NotFound); + }; + + Ok(CacheResult::Pending { auction_deadline }) + } + BidCacheEntry::Complete { bids } => Ok(CacheResult::Complete { bids }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_key_uses_request_id_prefix() { + assert_eq!( + FastlyBidCache::cache_key_for_request_id("request-123"), + "ts-bids:request-123", + "should use the request-scoped bid cache prefix" + ); + } + + #[test] + fn default_uses_short_ttl() { + let cache = FastlyBidCache::new(); + + assert_eq!( + cache.ttl, DEFAULT_BID_CACHE_TTL, + "should use the adapter default bid cache TTL" + ); + } + + #[test] + fn explicit_ttl_overrides_default() { + let cache = FastlyBidCache::with_ttl(Duration::from_secs(5)); + + assert_eq!( + cache.ttl, + Duration::from_secs(5), + "should use the configured bid cache TTL" + ); + } + + #[test] + fn explicit_limits_set_deadline_reconstruction_bound() { + let cache = + FastlyBidCache::with_limits(Duration::from_secs(30), Duration::from_millis(500)); + + assert_eq!( + cache.max_reconstructed_wait, + Duration::from_millis(500), + "should use the configured bid wait bound" + ); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 52c869d7f..46a90afb7 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,14 +1,18 @@ use error_stack::Report; -use fastly::http::Method; +use fastly::http::{header, Method, StatusCode}; use fastly::{Request, Response}; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::bid_cache::{BidCache, BidMap, CacheResult, WaitResult}; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; +use trusted_server_core::creative_opportunities::CreativeOpportunitiesFile; use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -19,7 +23,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, PublisherResponse, + handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, + stream_publisher_body_with_ad_auction, PublisherResponse, ServerSideAuctionStreamContext, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -28,6 +33,7 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; +mod bid_cache; mod error; mod logging; mod management_api; @@ -35,10 +41,19 @@ mod platform; #[cfg(test)] mod route_tests; +use crate::bid_cache::FastlyBidCache; use crate::error::to_error_response; use crate::logging::init_logger; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; +const TS_BIDS_NO_STORE: &str = "private, no-store"; +const TS_BIDS_POLL_INTERVAL: Duration = Duration::from_millis(5); +const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); + +static CREATIVE_OPPORTUNITIES: LazyLock = LazyLock::new(|| { + toml::from_str(CREATIVE_OPPORTUNITIES_TOML).expect("should parse creative-opportunities.toml") +}); + /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `Request::from_client()` instead of @@ -95,6 +110,7 @@ fn main() { let kv_store = std::sync::Arc::new(UnavailableKvStore) as std::sync::Arc; let runtime_services = build_runtime_services(&req, kv_store); + let bid_cache = FastlyBidCache::new(); // route_request may send the response directly (streaming path) or // return it for us to send (buffered path). @@ -103,6 +119,8 @@ fn main() { &orchestrator, &integration_registry, &runtime_services, + &CREATIVE_OPPORTUNITIES, + &bid_cache, req, )) { response.send_to_client(); @@ -114,6 +132,8 @@ async fn route_request( orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, runtime_services: &RuntimeServices, + creative_opportunities: &CreativeOpportunitiesFile, + bid_cache: &impl BidCache, mut req: Request, ) -> Option { // Strip client-spoofable forwarded headers at the edge. @@ -154,6 +174,8 @@ async fn route_request( // Match known routes and handle them let result = match (method, path.as_str()) { + (Method::GET, "/ts-bids") => Ok(handle_ts_bids_request(&req, bid_cache)), + // Serve the tsjs library (Method::GET, path) if path.starts_with("/static/tsjs=") => { handle_tsjs_dynamic(&req, integration_registry) @@ -221,23 +243,51 @@ async fn route_request( settings, integration_registry, &publisher_services, + orchestrator, + creative_opportunities, + bid_cache, req, ) { Ok(PublisherResponse::Stream { mut response, body, params, + mut server_side_auction, }) => { // Streaming path: finalize headers, then stream body to client. + let preserve_no_store = response + .get_header_str(header::CACHE_CONTROL) + .is_some_and(|value| value == TS_BIDS_NO_STORE); finalize_response(settings, geo_info.as_ref(), &mut response); + if preserve_no_store { + response.set_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE); + } let mut streaming_body = response.stream_to_client(); - if let Err(e) = stream_publisher_body( - body, - &mut streaming_body, - ¶ms, - settings, - integration_registry, - ) { + let stream_result = + if let Some(auction) = server_side_auction.as_deref_mut() { + let mut auction_context = ServerSideAuctionStreamContext { + services: &publisher_services, + bid_cache, + server_side_auction: auction, + }; + stream_publisher_body_with_ad_auction( + body, + &mut streaming_body, + ¶ms, + settings, + integration_registry, + &mut auction_context, + ) + } else { + stream_publisher_body( + body, + &mut streaming_body, + ¶ms, + settings, + integration_registry, + ) + }; + if let Err(e) = stream_result { // Headers already committed. Log and abort — client // sees a truncated response. Standard proxy behavior. log::error!("Streaming processing failed: {e:?}"); @@ -270,12 +320,85 @@ async fn route_request( // Convert any errors to HTTP error responses let mut response = result.unwrap_or_else(|e| to_error_response(&e)); + let preserve_no_store = response + .get_header_str(header::CACHE_CONTROL) + .is_some_and(|value| value == TS_BIDS_NO_STORE); finalize_response(settings, geo_info.as_ref(), &mut response); + if path == "/ts-bids" || preserve_no_store { + response.set_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE); + } Some(response) } +const MAX_REQUEST_ID_BYTES: usize = 256; + +fn handle_ts_bids_request(req: &Request, bid_cache: &impl BidCache) -> Response { + let Some(request_id) = req + .get_url() + .query_pairs() + .find_map(|(key, value)| (key == "rid").then_some(value.into_owned())) + .filter(|value| !value.is_empty() && value.len() <= MAX_REQUEST_ID_BYTES) + else { + return text_ts_bids_response(StatusCode::BAD_REQUEST, "missing rid\n"); + }; + + match bid_cache.try_get(&request_id) { + Ok(CacheResult::Complete { bids }) => json_ts_bids_response(StatusCode::OK, &bids), + Ok(CacheResult::Pending { auction_deadline }) => { + match wait_for_ts_bids(bid_cache, &request_id, auction_deadline.instant) { + WaitResult::Bids(bids) => json_ts_bids_response(StatusCode::OK, &bids), + WaitResult::Empty => empty_ts_bids_response(), + WaitResult::NotFound => text_ts_bids_response(StatusCode::NOT_FOUND, "not found\n"), + } + } + Ok(CacheResult::NotFound) => text_ts_bids_response(StatusCode::NOT_FOUND, "not found\n"), + Err(error) => { + log::warn!("Failed to read bid cache: {error:?}"); + empty_ts_bids_response() + } + } +} + +fn wait_for_ts_bids(bid_cache: &impl BidCache, request_id: &str, deadline: Instant) -> WaitResult { + loop { + match bid_cache.try_get(request_id) { + Ok(CacheResult::Complete { bids }) => return WaitResult::Bids(bids), + Ok(CacheResult::NotFound) => return WaitResult::NotFound, + Ok(CacheResult::Pending { .. }) => { + if Instant::now() >= deadline { + return WaitResult::Empty; + } + std::thread::sleep(TS_BIDS_POLL_INTERVAL); + } + Err(error) => { + log::warn!("Failed to poll bid cache: {error:?}"); + return WaitResult::Empty; + } + } + } +} + +fn empty_ts_bids_response() -> Response { + json_ts_bids_response(StatusCode::OK, &BidMap::new()) +} + +fn json_ts_bids_response(status: StatusCode, bids: &BidMap) -> Response { + let body = serde_json::to_string(&bids).expect("should serialize bid map"); + + Response::from_status(status) + .with_header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .with_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE) + .with_body(body) +} + +fn text_ts_bids_response(status: StatusCode, body: &str) -> Response { + Response::from_status(status) + .with_header(header::CACHE_CONTROL, TS_BIDS_NO_STORE) + .with_body_text_plain(body) +} + fn runtime_services_for_consent_route( settings: &Settings, runtime_services: &RuntimeServices, diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index dd1f098b8..aa72bfe95 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -12,6 +12,7 @@ use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; use fastly::geo::geo_lookup; +use fastly::http::request::{PendingRequest, PollResult}; use fastly::{ConfigStore, Request, SecretStore}; use trusted_server_core::backend::BackendConfig; @@ -20,8 +21,8 @@ pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, - StoreName, + PlatformPollResult, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -254,6 +255,13 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { async fn send_async( &self, request: PlatformHttpRequest, + ) -> Result> { + self.send_async_now(request) + } + + fn send_async_now( + &self, + request: PlatformHttpRequest, ) -> Result> { let backend_name = request.backend_name.clone(); let fastly_req = edge_request_to_fastly(request.request)?; @@ -315,6 +323,53 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { Ok(PlatformSelectResult { ready, remaining }) } + + async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result> { + self.poll_now(pending) + } + + fn poll_now( + &self, + pending: PlatformPendingRequest, + ) -> Result> { + let backend_name = pending.backend_name().map(str::to_string); + let inner = pending.downcast::().map_err(|platform_req| { + let backend_name = platform_req.backend_name().unwrap_or(""); + Report::new(PlatformError::HttpClient).attach(format!( + "PlatformPendingRequest inner type is not fastly::PendingRequest for backend '{backend_name}'" + )) + })?; + + match inner.poll() { + PollResult::Pending(pending) => { + let mut platform_pending = PlatformPendingRequest::new(pending); + if let Some(backend_name) = backend_name { + platform_pending = platform_pending.with_backend_name(backend_name); + } + Ok(PlatformPollResult::Pending(platform_pending)) + } + PollResult::Done(Ok(fastly_resp)) => { + let backend_name = fastly_resp + .get_backend_name() + .unwrap_or_else(|| { + log::warn!("poll: response has no backend name, correlation will fail"); + "" + }) + .to_string(); + Ok(PlatformPollResult::Ready(fastly_response_to_platform( + fastly_resp, + backend_name, + ))) + } + PollResult::Done(Err(error)) => Ok(PlatformPollResult::Ready(Err(Report::new( + PlatformError::HttpClient, + ) + .attach(format!("fastly poll error: {error}"))))), + } + } } // --------------------------------------------------------------------------- @@ -583,6 +638,24 @@ mod tests { ); } + #[test] + fn fastly_platform_http_client_poll_returns_error_for_wrong_inner_type() { + let client = FastlyPlatformHttpClient; + let wrong = PlatformPendingRequest::new(42u32).with_backend_name("origin-a"); + let err = futures::executor::block_on(client.poll(wrong)) + .expect_err("should return error for wrong inner type"); + + assert!( + matches!(err.current_context(), &PlatformError::HttpClient), + "should be HttpClient error, got: {:?}", + err.current_context() + ); + assert!( + format!("{err:?}").contains("origin-a"), + "should include backend name in error report: {err:?}" + ); + } + #[test] fn fastly_platform_http_client_send_returns_error_for_streaming_body() { let client = FastlyPlatformHttpClient; diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 0fd0113f8..42ef98d4e 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -3,9 +3,13 @@ use std::sync::Arc; use edgezero_core::key_value_store::NoopKvStore; use error_stack::Report; -use fastly::http::StatusCode; -use fastly::Request; +use fastly::http::{header, StatusCode}; +use fastly::{Request, Response}; +use serde_json::json; +use std::time::{Duration, Instant}; use trusted_server_core::auction::build_orchestrator; +use trusted_server_core::bid_cache::{AuctionDeadline, BidMap, InMemoryBidCache}; +use trusted_server_core::creative_opportunities::CreativeOpportunitiesFile; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -16,7 +20,7 @@ use trusted_server_core::platform::{ use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME; use trusted_server_core::settings::Settings; -use super::route_request; +use super::{handle_ts_bids_request, route_request}; struct StubJwksConfigStore; @@ -178,12 +182,172 @@ fn test_runtime_services(req: &Request) -> RuntimeServices { .build() } +fn response_body(mut response: Response) -> String { + response.take_body_str() +} + +fn test_bid_cache() -> InMemoryBidCache { + InMemoryBidCache::new(Duration::from_secs(1), 8) +} + +fn empty_creative_opportunities() -> CreativeOpportunitiesFile { + CreativeOpportunitiesFile::default() +} + +fn immediate_deadline() -> AuctionDeadline { + AuctionDeadline::from_parts(Instant::now(), 1_700_000_000_000) +} + +fn slot_bid_map() -> BidMap { + BidMap::from([( + "atf_sidebar".to_string(), + json!({ + "hb_pb": "1.20", + "hb_bidder": "rubicon", + "hb_adid": "ad-123", + "burl": "https://bidder.example/bill" + }), + )]) +} + +#[test] +fn ts_bids_missing_rid_returns_bad_request_no_store() { + let cache = test_bid_cache(); + let response = handle_ts_bids_request(&Request::get("https://test.com/ts-bids"), &cache); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should reject missing request ID" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_empty_rid_returns_bad_request_no_store() { + let cache = test_bid_cache(); + let response = handle_ts_bids_request(&Request::get("https://test.com/ts-bids?rid="), &cache); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should reject empty request ID" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_unknown_rid_returns_not_found_no_store() { + let cache = test_bid_cache(); + let response = handle_ts_bids_request( + &Request::get("https://test.com/ts-bids?rid=missing"), + &cache, + ); + + assert_eq!( + response.get_status(), + StatusCode::NOT_FOUND, + "should return 404 for unknown request IDs" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_completed_rid_returns_bid_json_no_store() { + let cache = test_bid_cache(); + let bids = slot_bid_map(); + cache.put("rid-1", bids.clone()).expect("should store bids"); + + let response = + handle_ts_bids_request(&Request::get("https://test.com/ts-bids?rid=rid-1"), &cache); + assert_eq!( + response.get_status(), + StatusCode::OK, + "should return completed bids" + ); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/json; charset=utf-8"), + "should return JSON" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); + let body: serde_json::Value = + serde_json::from_str(&response_body(response)).expect("should parse JSON body"); + assert_eq!(body, json!(bids), "should serialize bid map"); +} + +#[test] +fn ts_bids_completed_empty_map_returns_empty_json_no_store() { + let cache = test_bid_cache(); + cache + .put_empty("rid-empty") + .expect("should store empty bid map"); + + let response = handle_ts_bids_request( + &Request::get("https://test.com/ts-bids?rid=rid-empty"), + &cache, + ); + + assert_eq!(response.get_status(), StatusCode::OK, "should return OK"); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); + assert_eq!( + response_body(response), + "{}", + "should return empty JSON object" + ); +} + +#[test] +fn ts_bids_pending_until_original_deadline_returns_empty_json() { + let cache = test_bid_cache(); + cache + .mark_pending("rid-pending", immediate_deadline()) + .expect("should mark pending"); + + let response = handle_ts_bids_request( + &Request::get("https://test.com/ts-bids?rid=rid-pending"), + &cache, + ); + + assert_eq!( + response.get_status(), + StatusCode::OK, + "should return OK after pending deadline" + ); + assert_eq!( + response_body(response), + "{}", + "should return empty JSON object" + ); +} + #[test] fn configured_missing_consent_store_only_breaks_consent_routes() { let settings = create_test_settings(); let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); + let bid_cache = test_bid_cache(); let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); let discovery_services = test_runtime_services(&discovery_req); @@ -192,6 +356,8 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &discovery_services, + &empty_creative_opportunities(), + &bid_cache, discovery_req, )) .expect("should route discovery request"); @@ -208,6 +374,8 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &admin_services, + &empty_creative_opportunities(), + &bid_cache, admin_req, )) .expect("should route admin request"); @@ -224,6 +392,8 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &auction_services, + &empty_creative_opportunities(), + &bid_cache, auction_req, )) .expect("should return an error response for auction requests"); @@ -240,6 +410,8 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &publisher_services, + &empty_creative_opportunities(), + &bid_cache, publisher_req, )) .expect("should return an error response for publisher fallback"); @@ -249,3 +421,68 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { "should scope consent store failures to the consent-dependent routes" ); } + +#[test] +fn ts_bids_route_is_handled_before_publisher_fallback() { + let settings = create_test_settings(); + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let req = Request::get("https://test.com/ts-bids"); + let runtime_services = test_runtime_services(&req); + let bid_cache = test_bid_cache(); + + let response = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &runtime_services, + &empty_creative_opportunities(), + &bid_cache, + req, + )) + .expect("should route ts-bids request"); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should handle ts-bids before consent-dependent publisher fallback" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent browser caching" + ); +} + +#[test] +fn ts_bids_route_keeps_no_store_after_response_header_finalization() { + let mut settings = create_test_settings(); + settings.response_headers.insert( + header::CACHE_CONTROL.as_str().to_string(), + "public, max-age=300".to_string(), + ); + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let req = Request::get("https://test.com/ts-bids"); + let runtime_services = test_runtime_services(&req); + let bid_cache = test_bid_cache(); + + let response = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &runtime_services, + &empty_creative_opportunities(), + &bid_cache, + req, + )) + .expect("should route ts-bids request"); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "ts-bids no-store must win over configured response headers" + ); +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 9c69bd30b..569ecdd83 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -25,6 +25,7 @@ error-stack = { workspace = true } fastly = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } +glob = { workspace = true } hex = { workspace = true } hmac = { workspace = true } http = { workspace = true } @@ -54,6 +55,7 @@ edgezero-core = { workspace = true } config = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } +glob = { workspace = true } http = { workspace = true } log = { workspace = true } regex = { workspace = true } diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 6f56e85a2..a421331a8 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -15,6 +15,76 @@ mod redacted; #[path = "src/consent_config.rs"] mod consent_config; +#[path = "src/price_bucket.rs"] +mod price_bucket; + +// Build-script mirror of the creative opportunity config types. The runtime +// module also contains auction conversion helpers that depend on runtime-only +// auction/Fastly types, so build.rs keeps this narrow schema local. +mod creative_opportunities { + use std::collections::HashMap; + + use serde::{Deserialize, Serialize}; + + use crate::price_bucket::PriceGranularity; + + #[derive(Debug, Clone, Deserialize, Serialize)] + pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default)] + pub price_granularity: PriceGranularity, + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct CreativeOpportunitiesFile { + #[serde(rename = "slot", default)] + pub slots: Vec, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct CreativeOpportunitySlot { + pub id: String, + pub gam_unit_path: Option, + pub div_id: Option, + pub page_patterns: Vec, + pub formats: Vec, + pub floor_price: Option, + #[serde(default)] + pub targeting: HashMap, + #[serde(default)] + pub providers: SlotProviders, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct CreativeOpportunityFormat { + pub width: u32, + pub height: u32, + #[serde(default)] + pub media_type: MediaType, + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct SlotProviders { + pub aps: Option, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct ApsSlotParams { + pub slot_id: String, + } + + #[derive(Debug, Clone, Default, Deserialize)] + #[serde(rename_all = "lowercase")] + pub enum MediaType { + #[default] + Banner, + Video, + Native, + } +} + #[path = "src/settings.rs"] mod settings; @@ -23,6 +93,7 @@ use std::path::Path; const TRUSTED_SERVER_INIT_CONFIG_PATH: &str = "../../trusted-server.toml"; const TRUSTED_SERVER_OUTPUT_CONFIG_PATH: &str = "../../target/trusted-server-out.toml"; +const CREATIVE_OPPORTUNITIES_CONFIG_PATH: &str = "../../creative-opportunities.toml"; fn main() { // Always rerun build.rs: integration settings are stored in a flat @@ -30,6 +101,9 @@ fn main() { // var keys ahead of time. Emitting rerun-if-changed for a nonexistent // file forces cargo to always rerun the build script. println!("cargo:rerun-if-changed=_always_rebuild_sentinel_"); + println!("cargo:rerun-if-changed={CREATIVE_OPPORTUNITIES_CONFIG_PATH}"); + + validate_creative_opportunities_config(); // Read init config let init_config_path = Path::new(TRUSTED_SERVER_INIT_CONFIG_PATH); @@ -52,3 +126,51 @@ fn main() { .unwrap_or_else(|_| panic!("Failed to write {dest_path:?}")); } } + +fn validate_creative_opportunities_config() { + let config_path = Path::new(CREATIVE_OPPORTUNITIES_CONFIG_PATH); + let toml_content = fs::read_to_string(config_path) + .unwrap_or_else(|_| panic!("Failed to read {config_path:?}")); + + let parsed = toml::from_str::(&toml_content) + .unwrap_or_else(|err| panic!("Failed to parse {config_path:?}: {err}")); + let file = toml::from_str::(&toml_content) + .unwrap_or_else(|err| { + panic!("Invalid creative opportunity schema in {config_path:?}: {err}") + }); + + let Some(slots) = parsed.get("slot") else { + return; + }; + + let slots = slots + .as_array() + .unwrap_or_else(|| panic!("{config_path:?}: `slot` must be an array of tables")); + + let slot_id_regex = + regex::Regex::new(r"^[A-Za-z0-9_-]+$").expect("should compile slot ID validation regex"); + + for (slot, typed_slot) in slots.iter().zip(&file.slots) { + let id = slot + .get("id") + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("{config_path:?}: every [[slot]] must include string `id`")); + + if id != typed_slot.id { + panic!("{config_path:?}: slot ID validation schema mismatch for `{id}`"); + } + + if !slot_id_regex.is_match(id) { + panic!( + "{config_path:?}: invalid slot id `{id}`; use only ASCII letters, digits, `_`, and `-`" + ); + } + + for pattern in &typed_slot.page_patterns { + let normalized = pattern.replace("**", "*"); + if glob::Pattern::new(pattern).is_err() && glob::Pattern::new(&normalized).is_err() { + panic!("{config_path:?}: slot `{id}` has invalid page_pattern `{pattern}`"); + } + } + } +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 9cbcd2b93..93cd3f399 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::error::TrustedServerError; -use crate::platform::{PlatformPendingRequest, RuntimeServices}; +use crate::platform::{PlatformPendingRequest, PlatformPollResult, RuntimeServices}; use crate::proxy::platform_response_to_fastly; use super::config::AuctionConfig; @@ -23,12 +23,260 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } +fn select_winning_bids_from_responses( + responses: &[AuctionResponse], + floor_prices: &HashMap, +) -> HashMap { + let mut winning_bids: HashMap = HashMap::new(); + + for response in responses { + if response.status != BidStatus::Success { + continue; + } + + for bid in &response.bids { + let bid_price = match bid.price { + Some(price) => price, + None => { + log::debug!( + "Skipping bid for slot '{}' from '{}' - price requires mediation to decode", + bid.slot_id, + bid.bidder + ); + continue; + } + }; + + let should_replace = match winning_bids.get(&bid.slot_id) { + Some(current_winner) => current_winner + .price + .is_none_or(|current_price| bid_price > current_price), + None => true, + }; + + if should_replace { + winning_bids.insert(bid.slot_id.clone(), bid.clone()); + } + } + } + + if floor_prices.is_empty() { + log::info!("Selected {} winning bids", winning_bids.len()); + return winning_bids; + } + + let starting_count = winning_bids.len(); + winning_bids.retain(|slot_id, bid| match floor_prices.get(slot_id) { + Some(floor) => match bid.price { + Some(price) if price >= *floor => true, + Some(_) => { + log::info!("Dropping winning bid below floor price for slot '{slot_id}'"); + false + } + None => true, + }, + None => true, + }); + + if winning_bids.len() != starting_count { + log::info!( + "Filtered winning bids by floor price: {} -> {}", + starting_count, + winning_bids.len() + ); + } + + log::info!("Selected {} winning bids", winning_bids.len()); + winning_bids +} + /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { config: AuctionConfig, providers: HashMap>, } +/// Server-side template auction that can advance without blocking page streaming. +pub struct PendingAuction { + request: AuctionRequest, + pending: Vec, + provider_responses: Vec, + floor_prices: HashMap, + auction_started_at: Instant, + auction_deadline: Instant, +} + +struct PendingProviderRequest { + provider: Arc, + started_at: Instant, + pending: PlatformPendingRequest, +} + +/// Result of a non-blocking pending auction poll. +pub enum PendingAuctionPoll { + /// At least one provider is still in flight. + Pending, + /// The auction completed or reached its original deadline. + Complete(OrchestrationResult), +} + +impl PendingAuction { + /// Builds a completed pending auction for stream-polling tests. + #[cfg(test)] + pub(crate) fn from_completed_result_for_test( + request: AuctionRequest, + result: OrchestrationResult, + ) -> Self { + Self { + request, + pending: Vec::new(), + provider_responses: result.provider_responses, + floor_prices: HashMap::new(), + auction_started_at: Instant::now(), + auction_deadline: Instant::now(), + } + } + + /// Advance provider requests once without blocking. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the platform poll operation fails. + pub async fn poll_once( + &mut self, + services: &RuntimeServices, + ) -> Result> { + let mut still_pending = Vec::with_capacity(self.pending.len()); + + for pending_provider in std::mem::take(&mut self.pending) { + let PendingProviderRequest { + provider, + started_at, + pending, + } = pending_provider; + let provider_name = provider.provider_name(); + let poll_result = services.http_client().poll(pending).await.change_context( + TrustedServerError::Auction { + message: format!("HTTP poll failed for provider '{provider_name}'"), + }, + )?; + self.record_poll_result(provider, started_at, poll_result, &mut still_pending); + } + + self.finish_poll_round(still_pending) + } + + /// Advance provider requests once without blocking from a synchronous call + /// site. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the platform poll operation fails. + pub fn poll_once_now( + &mut self, + services: &RuntimeServices, + ) -> Result> { + let mut still_pending = Vec::with_capacity(self.pending.len()); + + for pending_provider in std::mem::take(&mut self.pending) { + let PendingProviderRequest { + provider, + started_at, + pending, + } = pending_provider; + let provider_name = provider.provider_name(); + let poll_result = services.http_client().poll_now(pending).change_context( + TrustedServerError::Auction { + message: format!("HTTP poll failed for provider '{provider_name}'"), + }, + )?; + self.record_poll_result(provider, started_at, poll_result, &mut still_pending); + } + + self.finish_poll_round(still_pending) + } + + fn finish_poll_round( + &mut self, + still_pending: Vec, + ) -> Result> { + self.pending = still_pending; + + if self.pending.is_empty() || Instant::now() >= self.auction_deadline { + Ok(PendingAuctionPoll::Complete(self.finish_due_to_deadline())) + } else { + Ok(PendingAuctionPoll::Pending) + } + } + + fn record_poll_result( + &mut self, + provider: Arc, + started_at: Instant, + poll_result: PlatformPollResult, + still_pending: &mut Vec, + ) { + match poll_result { + PlatformPollResult::Pending(pending) => { + still_pending.push(PendingProviderRequest { + provider, + started_at, + pending, + }); + } + PlatformPollResult::Ready(Ok(platform_response)) => { + let response_time_ms = started_at.elapsed().as_millis() as u64; + match platform_response_to_fastly(platform_response).and_then(|response| { + provider.parse_response_for_request(response, response_time_ms, &self.request) + }) { + Ok(response) => self.provider_responses.push(response), + Err(error) => { + log::warn!( + "Provider '{}' failed during non-blocking auction poll: {error:?}", + provider.provider_name() + ); + self.provider_responses.push(AuctionResponse::error( + provider.provider_name(), + response_time_ms, + )); + } + } + } + PlatformPollResult::Ready(Err(error)) => { + log::warn!( + "Provider '{}' poll completed with error: {error:?}", + provider.provider_name() + ); + self.provider_responses.push(AuctionResponse::error( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + } + } + } + + /// Finish the auction using responses collected so far. + #[must_use] + pub fn finish_due_to_deadline(&self) -> OrchestrationResult { + OrchestrationResult { + provider_responses: self.provider_responses.clone(), + mediator_response: None, + winning_bids: select_winning_bids_from_responses( + &self.provider_responses, + &self.floor_prices, + ), + total_time_ms: self.auction_started_at.elapsed().as_millis() as u64, + metadata: HashMap::new(), + } + } + + /// Return whether no provider requests remain in flight. + #[must_use] + pub fn is_complete(&self) -> bool { + self.pending.is_empty() + } +} + impl AuctionOrchestrator { /// Create a new orchestrator with the given configuration. #[must_use] @@ -95,6 +343,98 @@ impl AuctionOrchestrator { }) } + /// Start a server-side template auction without waiting for provider responses. + /// + /// The returned [`PendingAuction`] must be advanced with + /// [`PendingAuction::poll_once`] while the page response streams. + /// + /// # Errors + /// + /// Returns an error only when launching a provider request fails in a way + /// that should abort the auction setup. Individual provider launch failures + /// are logged and skipped. + pub fn start_server_side_auction( + &self, + request: AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + if self.config.has_mediator() { + return Err(Report::new(TrustedServerError::Auction { + message: "server-side template auctions do not support mediation yet; disable the mediator for this Fastly Phase 1 path".to_string(), + })); + } + + let auction_started_at = Instant::now(); + let auction_deadline = auction_started_at + .checked_add(Duration::from_millis(u64::from(context.timeout_ms))) + .unwrap_or(auction_started_at); + let mut pending = Vec::new(); + + for provider_name in self.config.provider_names() { + let Some(provider) = self.providers.get(provider_name).cloned() else { + log::warn!("Provider '{}' not registered, skipping", provider_name); + continue; + }; + + if !provider.is_enabled() { + log::debug!( + "Provider '{}' is disabled, skipping", + provider.provider_name() + ); + continue; + } + + let remaining_ms = remaining_budget_ms(auction_started_at, context.timeout_ms); + let effective_timeout = remaining_ms.min(provider.timeout_ms()); + if effective_timeout == 0 { + log::warn!( + "Auction timeout ({}ms) exhausted before launching '{}' — skipping", + context.timeout_ms, + provider.provider_name() + ); + continue; + } + + let provider_context = AuctionContext { + settings: context.settings, + request: context.request, + client_info: context.client_info, + timeout_ms: effective_timeout, + provider_responses: context.provider_responses, + services: context.services, + }; + + match provider.request_bids(&request, &provider_context) { + Ok(provider_pending) => { + let mut platform_pending = PlatformPendingRequest::new(provider_pending); + if let Some(backend_name) = provider.backend_name(effective_timeout) { + platform_pending = platform_pending.with_backend_name(backend_name); + } + pending.push(PendingProviderRequest { + provider, + started_at: Instant::now(), + pending: platform_pending, + }); + } + Err(error) => { + log::warn!( + "Provider '{}' failed to launch request: {error:?}", + provider.provider_name() + ); + } + } + } + + Ok(PendingAuction { + floor_prices: self.floor_prices_by_slot(&request), + request, + pending, + provider_responses: Vec::new(), + auction_started_at, + auction_deadline, + }) + } + /// Run auction with parallel bidding + mediation. /// /// Flow: @@ -176,7 +516,7 @@ impl AuctionOrchestrator { let response_time_ms = start_time.elapsed().as_millis() as u64; let mediator_resp = mediator - .parse_response(backend_response, response_time_ms) + .parse_response_for_request(backend_response, response_time_ms, request) .change_context(TrustedServerError::Auction { message: format!("Mediator {} parse failed", mediator.provider_name()), })?; @@ -402,7 +742,11 @@ impl AuctionOrchestrator { match platform_response_to_fastly(platform_response) { Ok(response) => { - match provider.parse_response(response, response_time_ms) { + match provider.parse_response_for_request( + response, + response_time_ms, + request, + ) { Ok(auction_response) => { log::info!( "Provider '{}' returned {} bids (status: {:?}, time: {}ms)", @@ -474,42 +818,7 @@ impl AuctionOrchestrator { responses: &[AuctionResponse], floor_prices: &HashMap, ) -> HashMap { - let mut winning_bids: HashMap = HashMap::new(); - - for response in responses { - if response.status != BidStatus::Success { - continue; - } - - for bid in &response.bids { - // Skip bids without decoded prices (e.g., APS bids) - // These require mediation layer to decode - let bid_price = match bid.price { - Some(p) => p, - None => { - log::debug!( - "Skipping bid for slot '{}' from '{}' - price requires mediation to decode", - bid.slot_id, - bid.bidder - ); - continue; - } - }; - - let should_replace = match winning_bids.get(&bid.slot_id) { - Some(current_winner) => current_winner - .price - .is_none_or(|current_price| bid_price > current_price), - None => true, - }; - - if should_replace { - winning_bids.insert(bid.slot_id.clone(), bid.clone()); - } - } - } - - self.apply_floor_prices(winning_bids, floor_prices) + select_winning_bids_from_responses(responses, floor_prices) } fn apply_floor_prices( @@ -633,10 +942,12 @@ impl OrchestrationResult { #[cfg(test)] mod tests { use crate::auction::config::AuctionConfig; + use crate::auction::provider::AuctionProvider; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionRequest, Bid, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionRequest, AuctionResponse, Bid, MediaType, PublisherInfo, UserInfo, }; + use crate::error::TrustedServerError; // All-None ClientInfo used across tests that don't need real IP/TLS data. // Defined as a const so &EMPTY_CLIENT_INFO has 'static lifetime, avoiding @@ -646,12 +957,19 @@ mod tests { tls_protocol: None, tls_cipher: None, }; - use crate::platform::test_support::noop_services; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; + use crate::platform::{PlatformHttpClient, PlatformHttpRequest}; use crate::test_support::tests::crate_test_settings_str; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use error_stack::Report; use fastly::Request; use std::collections::{HashMap, HashSet}; + use std::sync::Arc; - use super::AuctionOrchestrator; + use super::{AuctionOrchestrator, PendingAuction, PendingAuctionPoll, PendingProviderRequest}; fn create_test_auction_request() -> AuctionRequest { AuctionRequest { @@ -700,6 +1018,59 @@ mod tests { crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") } + struct TestAuctionProvider; + + impl AuctionProvider for TestAuctionProvider { + fn provider_name(&self) -> &'static str { + "test-provider" + } + + fn request_bids( + &self, + _request: &AuctionRequest, + _context: &crate::auction::types::AuctionContext<'_>, + ) -> Result> { + Err(Report::new(TrustedServerError::Auction { + message: "test provider does not launch real Fastly requests".to_string(), + })) + } + + fn parse_response( + &self, + mut response: fastly::Response, + response_time_ms: u64, + ) -> Result> { + let body = response.take_body_str(); + let price = body.parse::().unwrap_or(1.5); + Ok(AuctionResponse::success( + self.provider_name(), + vec![Bid { + slot_id: "header-banner".to_string(), + price: Some(price), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "test-bidder".to_string(), + width: 728, + height: 90, + nurl: None, + burl: None, + ad_id: Some("ad-123".to_string()), + metadata: HashMap::new(), + }], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some("backend-poll".to_string()) + } + } + #[test] fn filters_winning_bids_below_floor() { let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); @@ -722,6 +1093,7 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), }, ); @@ -738,6 +1110,7 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), }, ); @@ -756,6 +1129,84 @@ mod tests { ); } + #[test] + fn pending_auction_poll_once_completes_ready_provider_response() { + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(200, b"2.25".to_vec()); + let services = build_services_with_http_client(http_client.clone()); + let pending = futures::executor::block_on( + http_client.send_async(PlatformHttpRequest::new( + request_builder() + .method("POST") + .uri("https://bidder.example/openrtb2/auction") + .body(Body::empty()) + .expect("should build platform request"), + "backend-poll", + )), + ) + .expect("should create pending request"); + let provider = Arc::new(TestAuctionProvider); + let request = create_test_auction_request(); + let floor_prices = HashMap::from([("header-banner".to_string(), 1.50)]); + let mut pending_auction = PendingAuction { + request, + pending: vec![PendingProviderRequest { + provider, + started_at: std::time::Instant::now(), + pending, + }], + provider_responses: Vec::new(), + floor_prices, + auction_started_at: std::time::Instant::now(), + auction_deadline: std::time::Instant::now() + std::time::Duration::from_millis(50), + }; + + let result = + futures::executor::block_on(pending_auction.poll_once(&services)).expect("should poll"); + + let PendingAuctionPoll::Complete(result) = result else { + panic!("should complete after ready provider response"); + }; + let winner = result + .winning_bids + .get("header-banner") + .expect("should select winning bid"); + assert_eq!(winner.price, Some(2.25), "should parse provider response"); + assert!( + pending_auction.is_complete(), + "should have no pending providers after completion" + ); + } + + #[test] + fn server_side_auction_rejects_mediator_config_instead_of_bypassing_it() { + let config = AuctionConfig { + enabled: true, + providers: Vec::new(), + mediator: Some("gam".to_string()), + timeout_ms: 2000, + creative_store: "creative_store".to_string(), + allowed_context_keys: HashSet::new(), + }; + let orchestrator = AuctionOrchestrator::new(config); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = Request::get("https://test.com/test"); + let context = create_test_auction_context(&settings, &req, &EMPTY_CLIENT_INFO, 2000); + + let error = match orchestrator.start_server_side_auction(request, &context) { + Ok(_) => { + panic!("mediated server-side template auctions should be explicit unsupported") + } + Err(error) => error, + }; + + assert!( + error.to_string().contains("mediation"), + "should not silently run the non-mediated pending-auction path" + ); + } + // TODO: Re-enable provider integration tests after implementing mock support // for send_async(). Mock providers can't create PendingRequest without real // Fastly backends. @@ -872,6 +1323,7 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, metadata: { let mut m = HashMap::new(); m.insert( diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index cd3fcfc37..ca11d54ca 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -44,6 +44,24 @@ pub trait AuctionProvider: Send + Sync { response_time_ms: u64, ) -> Result>; + /// Parse the response from the provider with access to the original request. + /// + /// Providers whose response identifiers differ from Trusted Server slot IDs + /// can override this to restore logical slot IDs. + /// + /// # Errors + /// + /// Returns an error if the response cannot be parsed into a valid + /// [`AuctionResponse`]. + fn parse_response_for_request( + &self, + response: fastly::Response, + response_time_ms: u64, + _request: &AuctionRequest, + ) -> Result> { + self.parse_response(response, response_time_ms) + } + /// Check if this provider supports a specific media type. fn supports_media_type(&self, media_type: &super::types::MediaType) -> bool { // By default, support banner ads diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 17db59906..7b360a109 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -60,6 +60,14 @@ pub enum MediaType { Native, } +impl MediaType { + /// Returns [`MediaType::Banner`]; used as a serde default function pointer. + #[must_use] + pub const fn banner() -> Self { + Self::Banner + } +} + /// Publisher information. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PublisherInfo { @@ -152,6 +160,9 @@ pub struct Bid { pub nurl: Option, /// Billing notification URL pub burl: Option, + /// Provider ad ID used for `hb_adid` targeting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ad_id: Option, /// Provider-specific bid metadata /// For APS bids, contains encoded price in "amznbid" field pub metadata: HashMap, @@ -276,10 +287,36 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), } } + #[test] + fn media_type_banner_fn_returns_banner() { + assert_eq!(MediaType::banner(), MediaType::Banner); + } + + #[test] + fn bid_has_ad_id_field_for_gam_targeting() { + let bid = Bid { + slot_id: "atf".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: HashMap::new(), + }; + + assert_eq!(bid.ad_id.as_deref(), Some("prebid-ad-id-abc")); + } + #[test] fn provider_summary_from_successful_response() { let response = AuctionResponse::success( diff --git a/crates/trusted-server-core/src/bid_cache.rs b/crates/trusted-server-core/src/bid_cache.rs new file mode 100644 index 000000000..1b23a0dcb --- /dev/null +++ b/crates/trusted-server-core/src/bid_cache.rs @@ -0,0 +1,543 @@ +//! Request-scoped bid cache rendezvous types. + +use error_stack::Report; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Maximum default wait reconstructed from a persisted epoch deadline. +/// +/// Callers with a configured auction timeout should use +/// [`AuctionDeadline::from_epoch_ms_with_max_remaining`] instead. +pub const DEFAULT_MAX_RECONSTRUCTED_WAIT: Duration = Duration::from_secs(30); + +/// Request-scoped bid map keyed by ad slot identifier. +pub type BidMap = HashMap; + +/// Serialized bid cache entry stored by a platform adapter. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub enum BidCacheEntry { + /// Auction has started and may complete before the original deadline. + Pending { + /// Absolute auction deadline in Unix epoch milliseconds. + auction_deadline_epoch_ms: u64, + }, + /// Auction has completed, including the empty no-bid case. + Complete { + /// Bids keyed by ad slot identifier. + bids: BidMap, + }, +} + +/// Original auction deadline represented in local and absolute time. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AuctionDeadline { + /// Local monotonic instant used by in-process wait loops. + pub instant: Instant, + /// Absolute Unix epoch millisecond deadline persisted across requests. + pub epoch_ms: u64, +} + +impl AuctionDeadline { + /// Builds an [`AuctionDeadline`] from explicit local and absolute times. + #[must_use] + pub fn from_parts(instant: Instant, epoch_ms: u64) -> Self { + Self { instant, epoch_ms } + } + + /// Builds an [`AuctionDeadline`] from a timeout measured from now. + /// + /// This computes both the monotonic [`Instant`] and Unix epoch millisecond + /// deadline once so later paths can reuse the same timeout. + #[must_use] + pub fn from_timeout(timeout: Duration) -> Self { + let now_instant = Instant::now(); + let now_epoch = SystemTime::now(); + let instant = now_instant.checked_add(timeout).unwrap_or(now_instant); + let epoch_deadline = now_epoch.checked_add(timeout).unwrap_or(now_epoch); + let epoch_ms = epoch_deadline + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().try_into().unwrap_or(u64::MAX)) + .unwrap_or(0); + + Self { instant, epoch_ms } + } + + /// Reconstructs a local [`Instant`] from a persisted Unix epoch deadline. + /// + /// Fastly stores only the epoch deadline in Core Cache. A later `/ts-bids` + /// request uses this helper to enforce that original absolute deadline + /// without minting a fresh timeout. + #[must_use] + pub fn from_epoch_ms(epoch_ms: u64) -> Option { + Self::from_epoch_ms_with_max_remaining(epoch_ms, DEFAULT_MAX_RECONSTRUCTED_WAIT) + } + + /// Reconstructs a local [`Instant`] from a persisted Unix epoch deadline + /// while capping the remaining wait. + /// + /// Returns [`None`] when the persisted deadline is implausibly far in the + /// future or cannot be represented locally. + #[must_use] + pub fn from_epoch_ms_with_max_remaining( + epoch_ms: u64, + max_remaining: Duration, + ) -> Option { + let now_instant = Instant::now(); + let now_epoch_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().try_into().unwrap_or(u64::MAX)) + .unwrap_or(0); + + let instant = if epoch_ms >= now_epoch_ms { + let remaining = Duration::from_millis(epoch_ms - now_epoch_ms); + if remaining > max_remaining { + return None; + } + now_instant.checked_add(remaining)? + } else { + now_instant + }; + + Some(Self { instant, epoch_ms }) + } +} + +/// Result of a bid cache lookup. +#[derive(Debug, Clone, PartialEq)] +pub enum CacheResult { + /// Auction completed and bids are available. + Complete { + /// Bids keyed by ad slot identifier. + bids: BidMap, + }, + /// Auction is still pending until the original auction deadline. + Pending { + /// Original auction deadline. + auction_deadline: AuctionDeadline, + }, + /// Request ID is unknown or expired. + NotFound, +} + +/// Result of waiting for bids. +#[derive(Debug, Clone, PartialEq)] +pub enum WaitResult { + /// Auction completed and bids are available. + Bids(BidMap), + /// Auction deadline elapsed before bids were available. + Empty, + /// Request ID is unknown or expired. + NotFound, +} + +/// Errors returned by bid cache implementations. +#[derive(Debug, derive_more::Display)] +pub enum BidCacheError { + /// In-memory bid cache lock was poisoned. + #[display("bid cache lock was poisoned")] + LockPoisoned, + /// Bid cache serialization failed. + #[display("bid cache serialization failed")] + Serialize, + /// Bid cache deserialization failed. + #[display("bid cache deserialization failed")] + Deserialize, + /// Bid cache I/O failed. + #[display("bid cache I/O failed")] + Io, + /// Platform cache operation failed. + #[display("platform bid cache operation failed")] + PlatformCache, +} + +impl core::error::Error for BidCacheError {} + +/// Bid cache result type using the repository-standard [`Report`] wrapper. +pub type BidCacheResult = Result>; + +/// Request-ID rendezvous for server-side auction state. +pub trait BidCache { + /// Marks a request ID as pending until the original auction deadline. + /// + /// # Errors + /// + /// Returns [`BidCacheError`] when the cache cannot be written. + fn mark_pending( + &self, + request_id: &str, + auction_deadline: AuctionDeadline, + ) -> BidCacheResult<()>; + + /// Stores completed bids for a request ID. + /// + /// # Errors + /// + /// Returns [`BidCacheError`] when the cache cannot be written. + fn put(&self, request_id: &str, bids: BidMap) -> BidCacheResult<()>; + + /// Looks up the current state for a request ID. + /// + /// # Errors + /// + /// Returns [`BidCacheError`] when the cache cannot be read. + fn try_get(&self, request_id: &str) -> BidCacheResult; +} + +/// In-memory [`BidCache`] implementation for tests and unsupported adapters. +pub struct InMemoryBidCache { + ttl: Duration, + capacity: usize, + inner: Mutex, +} + +#[derive(Default)] +struct BidCacheInner { + entries: HashMap, + insertion_order: VecDeque, +} + +#[derive(Debug, Clone)] +struct StoredBidCacheEntry { + entry: BidCacheEntry, + auction_deadline: Option, + inserted_at: Instant, +} + +impl InMemoryBidCache { + /// Creates an in-memory bid cache with a TTL and maximum capacity. + #[must_use] + pub fn new(ttl: Duration, capacity: usize) -> Self { + Self { + ttl, + capacity, + inner: Mutex::new(BidCacheInner::default()), + } + } + + /// Marks a request ID as pending until the original auction deadline. + /// + /// # Errors + /// + /// Returns [`BidCacheError::LockPoisoned`] when the cache lock is poisoned. + pub fn mark_pending( + &self, + request_id: &str, + auction_deadline: AuctionDeadline, + ) -> BidCacheResult<()> { + ::mark_pending(self, request_id, auction_deadline) + } + + /// Stores completed bids for a request ID. + /// + /// # Errors + /// + /// Returns [`BidCacheError::LockPoisoned`] when the cache lock is poisoned. + pub fn put(&self, request_id: &str, bids: BidMap) -> BidCacheResult<()> { + ::put(self, request_id, bids) + } + + /// Stores an empty completed bid map for a request ID. + /// + /// # Errors + /// + /// Returns [`BidCacheError::LockPoisoned`] when the cache lock is poisoned. + pub fn put_empty(&self, request_id: &str) -> BidCacheResult<()> { + self.put(request_id, BidMap::new()) + } + + /// Looks up the current state for a request ID. + /// + /// # Errors + /// + /// Returns [`BidCacheError::LockPoisoned`] when the cache lock is poisoned. + pub fn try_get(&self, request_id: &str) -> BidCacheResult { + ::try_get(self, request_id) + } + + /// Returns the original auction deadline for a pending request. + pub fn get_auction_deadline(&self, request_id: &str) -> Option { + let mut inner = self.inner.lock().ok()?; + self.remove_expired(&mut inner); + let stored = inner.entries.get(request_id)?; + + match stored.entry { + BidCacheEntry::Pending { .. } => stored.auction_deadline, + BidCacheEntry::Complete { .. } => None, + } + } + + /// Waits for bids until the original auction deadline. + pub fn wait_for(&self, request_id: &str, deadline: AuctionDeadline) -> WaitResult { + loop { + match self.try_get(request_id) { + Ok(CacheResult::Complete { bids }) => return WaitResult::Bids(bids), + Ok(CacheResult::NotFound) => return WaitResult::NotFound, + Ok(CacheResult::Pending { .. }) => { + if Instant::now() >= deadline.instant { + return WaitResult::Empty; + } + std::thread::sleep(Duration::from_millis(1)); + } + Err(_) => return WaitResult::NotFound, + } + } + } + + fn store_entry( + &self, + request_id: &str, + entry: BidCacheEntry, + auction_deadline: Option, + ) -> BidCacheResult<()> { + let mut inner = self + .inner + .lock() + .map_err(|_| Report::new(BidCacheError::LockPoisoned))?; + + self.remove_expired(&mut inner); + inner.entries.insert( + request_id.to_string(), + StoredBidCacheEntry { + entry, + auction_deadline, + inserted_at: Instant::now(), + }, + ); + inner.insertion_order.retain(|key| key != request_id); + inner.insertion_order.push_back(request_id.to_string()); + self.enforce_capacity(&mut inner); + + Ok(()) + } + + fn remove_expired(&self, inner: &mut BidCacheInner) { + inner + .entries + .retain(|_, stored| stored.inserted_at.elapsed() < self.ttl); + inner + .insertion_order + .retain(|key| inner.entries.contains_key(key)); + } + + fn enforce_capacity(&self, inner: &mut BidCacheInner) { + if self.capacity == 0 { + inner.entries.clear(); + inner.insertion_order.clear(); + return; + } + + while inner.entries.len() > self.capacity { + if let Some(oldest_key) = inner.insertion_order.pop_front() { + inner.entries.remove(&oldest_key); + } else { + break; + } + } + } +} + +impl BidCache for InMemoryBidCache { + fn mark_pending( + &self, + request_id: &str, + auction_deadline: AuctionDeadline, + ) -> BidCacheResult<()> { + self.store_entry( + request_id, + BidCacheEntry::Pending { + auction_deadline_epoch_ms: auction_deadline.epoch_ms, + }, + Some(auction_deadline), + ) + } + + fn put(&self, request_id: &str, bids: BidMap) -> BidCacheResult<()> { + self.store_entry(request_id, BidCacheEntry::Complete { bids }, None) + } + + fn try_get(&self, request_id: &str) -> BidCacheResult { + let mut inner = self + .inner + .lock() + .map_err(|_| Report::new(BidCacheError::LockPoisoned))?; + self.remove_expired(&mut inner); + + let Some(stored) = inner.entries.get(request_id) else { + return Ok(CacheResult::NotFound); + }; + + match &stored.entry { + BidCacheEntry::Pending { + auction_deadline_epoch_ms, + } => Ok(CacheResult::Pending { + auction_deadline: if let Some(deadline) = stored.auction_deadline { + deadline + } else if let Some(deadline) = + AuctionDeadline::from_epoch_ms(*auction_deadline_epoch_ms) + { + deadline + } else { + return Ok(CacheResult::NotFound); + }, + }), + BidCacheEntry::Complete { bids } => Ok(CacheResult::Complete { bids: bids.clone() }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::{Duration, Instant}; + + fn deadline_after(duration: Duration, epoch_ms: u64) -> AuctionDeadline { + AuctionDeadline { + instant: Instant::now() + duration, + epoch_ms, + } + } + + fn bid_map() -> BidMap { + BidMap::from([("slot-1".to_string(), json!({ "hb_pb": "1.20" }))]) + } + + #[test] + fn unknown_request_id_returns_not_found() { + let cache = InMemoryBidCache::new(Duration::from_secs(1), 8); + + let result = cache.try_get("missing").expect("should read cache"); + + assert_eq!(result, CacheResult::NotFound, "should miss unknown request"); + } + + #[test] + fn pending_request_id_returns_pending() { + let cache = InMemoryBidCache::new(Duration::from_secs(1), 8); + let deadline = deadline_after(Duration::from_millis(50), 1_700_000_000_000); + + cache + .mark_pending("rid-1", deadline) + .expect("should mark pending"); + + assert!( + matches!(cache.try_get("rid-1"), Ok(CacheResult::Pending { .. })), + "should return pending state" + ); + } + + #[test] + fn pending_entry_carries_original_auction_deadline() { + let cache = InMemoryBidCache::new(Duration::from_secs(1), 8); + let deadline = deadline_after(Duration::from_millis(50), 1_700_000_123_456); + + cache + .mark_pending("rid-1", deadline) + .expect("should mark pending"); + + let CacheResult::Pending { auction_deadline } = + cache.try_get("rid-1").expect("should read cache") + else { + panic!("should return pending state"); + }; + + assert_eq!( + auction_deadline, deadline, + "should preserve original auction deadline" + ); + } + + #[test] + fn completed_request_id_returns_bids() { + let cache = InMemoryBidCache::new(Duration::from_secs(1), 8); + let bids = bid_map(); + + cache.put("rid-1", bids.clone()).expect("should put bids"); + + assert_eq!( + cache.try_get("rid-1").expect("should read cache"), + CacheResult::Complete { bids }, + "should return completed bids" + ); + } + + #[test] + fn expired_entry_returns_not_found() { + let cache = InMemoryBidCache::new(Duration::ZERO, 8); + + cache.put("rid-1", bid_map()).expect("should put bids"); + + assert_eq!( + cache.try_get("rid-1").expect("should read cache"), + CacheResult::NotFound, + "should treat expired entries as missing" + ); + } + + #[test] + fn wait_for_returns_bids_immediately_when_complete() { + let cache = InMemoryBidCache::new(Duration::from_secs(1), 8); + let deadline = deadline_after(Duration::from_secs(1), 1_700_000_000_000); + let bids = bid_map(); + + cache.put("rid-1", bids.clone()).expect("should put bids"); + + assert_eq!( + cache.wait_for("rid-1", deadline), + WaitResult::Bids(bids), + "should return completed bids without waiting" + ); + } + + #[test] + fn wait_for_returns_empty_after_original_deadline() { + let cache = InMemoryBidCache::new(Duration::from_secs(1), 8); + let deadline = deadline_after(Duration::ZERO, 1_700_000_000_000); + + cache + .mark_pending("rid-1", deadline) + .expect("should mark pending"); + + assert_eq!( + cache.wait_for("rid-1", deadline), + WaitResult::Empty, + "should stop waiting at the original deadline" + ); + } + + #[test] + fn get_auction_deadline_returns_pending_original_deadline() { + let cache = InMemoryBidCache::new(Duration::from_secs(1), 8); + let deadline = deadline_after(Duration::from_millis(50), 1_700_000_123_456); + + cache + .mark_pending("rid-1", deadline) + .expect("should mark pending"); + + assert_eq!( + cache.get_auction_deadline("rid-1"), + Some(deadline), + "should return pending original deadline" + ); + } + + #[test] + fn reconstructing_epoch_deadline_rejects_implausibly_far_future() { + let now_epoch_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("should have system time after Unix epoch") + .as_millis() as u64; + let far_future_epoch_ms = now_epoch_ms + 10_000; + + assert_eq!( + AuctionDeadline::from_epoch_ms_with_max_remaining( + far_future_epoch_ms, + Duration::from_millis(1), + ), + None, + "should not mint a wait beyond the configured bound" + ); + } +} diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs new file mode 100644 index 000000000..f29acf1b4 --- /dev/null +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -0,0 +1,289 @@ +//! Creative opportunity slot templates and URL matching. + +use std::collections::HashMap; + +use glob::Pattern; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::auction::types::{AdFormat, AdSlot, MediaType}; +use crate::price_bucket::PriceGranularity; + +/// Global settings for creative opportunity matching. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreativeOpportunitiesConfig { + /// GAM network ID used when slot-level unit paths are omitted. + pub gam_network_id: String, + /// Auction timeout for server-side template auctions. + #[serde(default)] + pub auction_timeout_ms: Option, + /// Price granularity used when converting bids to GPT targeting. + #[serde(default = "PriceGranularity::dense")] + pub price_granularity: PriceGranularity, +} + +/// A URL-matched ad slot template. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreativeOpportunitySlot { + /// Stable Trusted Server slot ID. + pub id: String, + /// Optional full GAM unit path. + pub gam_unit_path: Option, + /// Optional HTML div ID for GPT slot definition. + pub div_id: Option, + /// URL path patterns that enable this slot. + pub page_patterns: Vec, + /// Supported creative formats. + pub formats: Vec, + /// Optional slot-level floor price. + pub floor_price: Option, + /// Static slot targeting. + #[serde(default)] + pub targeting: HashMap, + /// Provider-specific slot params. + #[serde(default)] + pub providers: SlotProviders, +} + +impl CreativeOpportunitySlot { + /// Returns whether this slot matches the provided URL path. + #[must_use] + pub fn matches_path(&self, path: &str) -> bool { + self.page_patterns + .iter() + .any(|pattern| pattern_matches_path(pattern, path)) + } + + /// Resolve the GAM unit path for this slot. + #[must_use] + pub fn resolved_gam_unit_path(&self, config: &CreativeOpportunitiesConfig) -> String { + self.gam_unit_path + .clone() + .unwrap_or_else(|| format!("/{}/{}", config.gam_network_id, self.id)) + } + + /// Resolve the GPT div ID for this slot. + #[must_use] + pub fn resolved_div_id(&self) -> String { + self.div_id.clone().unwrap_or_else(|| self.id.clone()) + } + + /// Convert this template into an auction [`AdSlot`]. + #[must_use] + pub fn to_ad_slot(&self) -> AdSlot { + let targeting = self + .targeting + .iter() + .map(|(key, value)| (key.clone(), json!(value))) + .collect(); + + let mut bidders = HashMap::new(); + if let Some(aps) = &self.providers.aps { + bidders.insert("aps".to_string(), json!({ "slotID": aps.slot_id })); + } + + AdSlot { + id: self.id.clone(), + formats: self + .formats + .iter() + .map(CreativeOpportunityFormat::to_ad_format) + .collect(), + floor_price: self.floor_price, + targeting, + bidders, + } + } +} + +/// A creative format supported by a slot. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreativeOpportunityFormat { + /// Width in CSS pixels. + pub width: u32, + /// Height in CSS pixels. + pub height: u32, + /// Media type for this format. + #[serde(default = "MediaType::banner")] + pub media_type: MediaType, +} + +impl CreativeOpportunityFormat { + fn to_ad_format(&self) -> AdFormat { + AdFormat { + media_type: self.media_type.clone(), + width: self.width, + height: self.height, + } + } +} + +/// Provider-specific params for a slot. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct SlotProviders { + /// APS/TAM slot params. + pub aps: Option, +} + +/// APS/TAM slot params. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApsSlotParams { + /// APS slot ID forwarded as `slotID`. + pub slot_id: String, +} + +/// Slot template file. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct CreativeOpportunitiesFile { + /// Slot templates in this file. + #[serde(rename = "slot", default)] + pub slots: Vec, +} + +/// Validate that a slot ID is safe for targeting keys and DOM-derived uses. +#[must_use] +pub fn validate_slot_id(id: &str) -> bool { + !id.is_empty() + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') +} + +/// Return all slots that match `path`. +#[must_use] +pub fn match_slots<'a>( + slots: &'a [CreativeOpportunitySlot], + path: &str, +) -> Vec<&'a CreativeOpportunitySlot> { + slots + .iter() + .filter(|slot| slot.matches_path(path)) + .collect() +} + +fn pattern_matches_path(pattern: &str, path: &str) -> bool { + if pattern == "/" { + return path == "/"; + } + + compile_url_pattern(pattern).is_some_and(|compiled| compiled.matches(path)) +} + +fn compile_url_pattern(pattern: &str) -> Option { + Pattern::new(pattern) + .or_else(|_| Pattern::new(&pattern.replace("**", "*"))) + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + 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 article_pattern_matches_multi_segment_article_paths() { + let slot = make_slot("atf", vec!["/20**"]); + + assert!(slot.matches_path("/2024/01/my-article")); + assert!(slot.matches_path("/2026/05/02/story")); + assert!(!slot.matches_path("/about")); + } + + #[test] + fn root_pattern_matches_homepage_only() { + let slot = make_slot("home", vec!["/"]); + + assert!(slot.matches_path("/")); + assert!(!slot.matches_path("/about")); + assert!(!slot.matches_path("/2024/01/my-article")); + } + + #[test] + fn slot_ids_allow_alnum_underscore_and_dash_only() { + assert!(validate_slot_id("atf_sidebar-1")); + assert!(validate_slot_id("A1_b-2")); + assert!(!validate_slot_id("")); + assert!(!validate_slot_id("atf/sidebar")); + assert!(!validate_slot_id("atf sidebar")); + assert!(!validate_slot_id("atf.sidebar")); + } + + #[test] + fn resolved_gam_unit_path_defaults_to_network_and_slot_id() { + let config = CreativeOpportunitiesConfig { + gam_network_id: "21765378893".to_string(), + auction_timeout_ms: Some(500), + price_granularity: crate::price_bucket::PriceGranularity::Dense, + }; + let mut slot = make_slot("atf_sidebar", vec!["/20**"]); + + assert_eq!( + slot.resolved_gam_unit_path(&config), + "/21765378893/atf_sidebar" + ); + + slot.gam_unit_path = Some("/21765378893/custom/path".to_string()); + assert_eq!( + slot.resolved_gam_unit_path(&config), + "/21765378893/custom/path" + ); + } + + #[test] + fn resolved_div_id_defaults_to_slot_id() { + let mut slot = make_slot("atf_sidebar", vec!["/20**"]); + + assert_eq!(slot.resolved_div_id(), "atf_sidebar"); + + slot.div_id = Some("div-atf-sidebar".to_string()); + assert_eq!(slot.resolved_div_id(), "div-atf-sidebar"); + } + + #[test] + fn to_ad_slot_transfers_formats_floor_targeting_and_aps_slot_id() { + let mut slot = make_slot("atf_sidebar", vec!["/20**"]); + slot.targeting.insert("pos".to_string(), "atf".to_string()); + slot.providers.aps = Some(ApsSlotParams { + slot_id: "aps-atf-sidebar".to_string(), + }); + + let ad_slot = slot.to_ad_slot(); + + assert_eq!(ad_slot.id, "atf_sidebar"); + assert_eq!(ad_slot.formats.len(), 1); + assert_eq!(ad_slot.formats[0].media_type, MediaType::Banner); + assert_eq!(ad_slot.formats[0].width, 300); + assert_eq!(ad_slot.formats[0].height, 250); + assert_eq!(ad_slot.floor_price, Some(0.50)); + assert_eq!(ad_slot.targeting.get("pos"), Some(&json!("atf"))); + assert_eq!( + ad_slot.bidders.get("aps").and_then(|v| v.get("slotID")), + Some(&json!("aps-atf-sidebar")) + ); + } + + #[test] + fn empty_slot_file_parses_and_produces_zero_matches() { + let file: CreativeOpportunitiesFile = toml::from_str("").expect("should parse empty file"); + + assert!(file.slots.is_empty()); + assert!(match_slots(&file.slots, "/2024/01/my-article").is_empty()); + } +} diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index a201a59d3..b7c45a092 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -133,6 +133,8 @@ pub struct HtmlProcessorConfig { pub origin_host: String, pub request_host: String, pub request_scheme: String, + /// Precomputed head globals script. Contains ad slots and request ID only. + pub ad_slots_script: Option, pub integrations: IntegrationRegistry, } @@ -150,6 +152,7 @@ impl HtmlProcessorConfig { origin_host: origin_host.to_string(), request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), + ad_slots_script: None, integrations: integrations.clone(), } } @@ -230,6 +233,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let injected_tsjs = Rc::new(Cell::new(false)); let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); + let ad_slots_script = Rc::new(config.ad_slots_script.clone()); let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -238,6 +242,7 @@ 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(); @@ -247,6 +252,9 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso origin_host: &patterns.origin_host, document_state: &document_state, }; + if let Some(script) = ad_slots_script.as_ref() { + snippet.push_str(script); + } // First inject integration-specific config (e.g., window.__tsjs_prebid) // so it's available when the bundle's auto-init code reads it. for insert in integrations.head_inserts(&ctx) { @@ -539,6 +547,7 @@ mod tests { origin_host: "origin.example.com".to_string(), request_host: "test.example.com".to_string(), request_scheme: "https".to_string(), + ad_slots_script: None, integrations: IntegrationRegistry::default(), } } @@ -668,6 +677,95 @@ mod tests { ); } + #[test] + fn ad_slots_script_is_prepended_at_head_open_before_other_head_inserts() { + struct TestHeadInjector; + + impl IntegrationHeadInjector for TestHeadInjector { + fn integration_id(&self) -> &'static str { + "test" + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + vec![r#""#.to_string()] + } + } + + let html = r#"Test"#; + + let mut config = create_test_config(); + config.ad_slots_script = Some( + r#""# + .to_string(), + ); + config.integrations = IntegrationRegistry::from_rewriters_with_head_injectors( + Vec::new(), + Vec::new(), + vec![Arc::new(TestHeadInjector)], + ); + + let processor = create_html_processor(config); + let pipeline_config = PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 8192, + }; + let mut pipeline = StreamingPipeline::new(pipeline_config, processor); + + let mut output = Vec::new(); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("pipeline should process HTML"); + let processed = String::from_utf8(output).expect("output should be valid UTF-8"); + + let ad_slots_index = processed + .find("window.__ts_ad_slots") + .expect("should inject ad slots global"); + let request_id_index = processed + .find("window.__ts_request_id") + .expect("should inject request ID global"); + let head_insert_index = processed + .find("window.__testHeadInjector=true") + .expect("should inject integration head insert"); + let tsjs_index = processed + .find("id=\"trustedserver-js\"") + .expect("should inject unified tsjs tag"); + let title_index = processed + .find("") + .expect("should keep existing head content"); + + assert_eq!( + processed.matches("window.__ts_ad_slots").count(), + 1, + "should inject ad slots global once" + ); + assert_eq!( + processed.matches("window.__ts_request_id").count(), + 1, + "should inject request ID global once" + ); + assert!( + ad_slots_index < head_insert_index, + "ad slots script should be first injected head content" + ); + assert!( + request_id_index < head_insert_index, + "request ID should be part of the first injected head content" + ); + assert!( + head_insert_index < tsjs_index, + "integration head inserts should remain before tsjs" + ); + assert!( + tsjs_index < title_index, + "all injected content should be prepended at the head open tag" + ); + assert!( + !processed.contains("window.__ts_bids"), + "HTML output must never include bid globals" + ); + } + #[test] fn test_create_html_processor_url_replacement() { let config = create_test_config(); diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index f4ce745db..7ed2da595 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -256,6 +256,7 @@ impl AdServerMockProvider { }), nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), }); } @@ -507,6 +508,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 +528,7 @@ mod tests { adomain: None, nurl: None, burl: None, + ad_id: None, metadata: HashMap::new(), }], response_time_ms: 120, @@ -666,6 +669,7 @@ mod tests { adomain: Some(vec!["amazon.com".to_string()]), nurl: None, burl: None, + ad_id: None, metadata: aps_metadata, }], response_time_ms: 100, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index af66dd064..aed6418e8 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -12,7 +12,9 @@ use std::time::Duration; use validator::Validate; use crate::auction::provider::AuctionProvider; -use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, MediaType}; +use crate::auction::types::{ + AdSlot, AuctionContext, AuctionRequest, AuctionResponse, Bid, MediaType, +}; use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::settings::IntegrationConfig; @@ -313,7 +315,7 @@ impl ApsAuctionProvider { .collect(); ApsSlot { - slot_id: slot.id.clone(), + slot_id: aps_slot_id(slot), sizes, slot_name: Some(slot.id.clone()), } @@ -366,7 +368,11 @@ impl ApsAuctionProvider { /// Note: Price is NOT decoded here. The encoded price is stored in metadata /// and will be decoded by the mediation layer (mocktioneer). This simulates /// real-world APS where only Amazon/GAM can decode the proprietary price encoding. - fn parse_aps_slot(&self, slot: &ApsSlotResponse) -> Result<Bid, ()> { + fn parse_aps_slot( + &self, + slot: &ApsSlotResponse, + slot_id_map: &HashMap<String, String>, + ) -> Result<Bid, ()> { // Only process filled slots (fif == "1") if slot.fif.as_deref() != Some("1") { return Err(()); @@ -384,6 +390,10 @@ impl ApsAuctionProvider { // Parse size from "WxH" format let (width, height) = Self::parse_size(&slot.size).unwrap_or((0, 0)); + let slot_id = slot_id_map + .get(&slot.slot_id) + .cloned() + .unwrap_or_else(|| slot.slot_id.clone()); // Build metadata from targeting keys - includes encoded price for mediation let mut metadata = HashMap::new(); @@ -407,7 +417,7 @@ impl ApsAuctionProvider { // The creative will be generated by the mediation layer // Price is None - will be decoded by mediation layer from amznbid metadata Ok(Bid { - slot_id: slot.slot_id.clone(), + slot_id, price: None, // Encoded price in metadata, decoded by mediation currency: "USD".to_string(), creative: None, @@ -417,12 +427,33 @@ impl ApsAuctionProvider { height, nurl: None, // Real APS uses client-side event tracking burl: None, + ad_id: None, metadata, }) } /// Parse APS TAM response into unified `AuctionResponse`. fn parse_aps_response(&self, json: &Json, response_time_ms: u64) -> AuctionResponse { + let slot_id_map = HashMap::new(); + self.parse_aps_response_with_slot_id_map(json, response_time_ms, &slot_id_map) + } + + fn parse_aps_response_for_request( + &self, + json: &Json, + response_time_ms: u64, + request: &AuctionRequest, + ) -> Result<AuctionResponse, Report<TrustedServerError>> { + let slot_id_map = aps_slot_id_to_logical_slot_ids(request)?; + Ok(self.parse_aps_response_with_slot_id_map(json, response_time_ms, &slot_id_map)) + } + + fn parse_aps_response_with_slot_id_map( + &self, + json: &Json, + response_time_ms: u64, + slot_id_map: &HashMap<String, String>, + ) -> AuctionResponse { let mut bids = Vec::new(); // Try to parse as ApsBidResponse with contextual wrapper @@ -433,7 +464,7 @@ impl ApsAuctionProvider { ); for slot in aps_response.contextual.slots { - match self.parse_aps_slot(&slot) { + match self.parse_aps_slot(&slot, slot_id_map) { Ok(bid) => { let encoded_price = bid .metadata @@ -466,6 +497,78 @@ impl ApsAuctionProvider { AuctionResponse::success("aps", bids, response_time_ms) } } + + fn parse_fastly_response( + &self, + mut response: fastly::Response, + response_time_ms: u64, + request: Option<&AuctionRequest>, + ) -> Result<AuctionResponse, Report<TrustedServerError>> { + // Check status code + if !response.get_status().is_success() { + log::warn!("APS returned non-success status: {}", response.get_status()); + return Ok(AuctionResponse::error("aps", response_time_ms)); + } + + // Parse response body + let body_bytes = response.take_body_bytes(); + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { + message: "Failed to parse APS response JSON".to_string(), + })?; + + log::trace!("APS: received response: {:?}", response_json); + + // Transform to unified format + let auction_response = request.map_or_else( + || Ok(self.parse_aps_response(&response_json, response_time_ms)), + |auction_request| { + self.parse_aps_response_for_request( + &response_json, + response_time_ms, + auction_request, + ) + }, + )?; + + log::info!( + "APS returned {} bids in {}ms", + auction_response.bids.len(), + response_time_ms + ); + + Ok(auction_response) + } +} + +fn aps_slot_id(slot: &AdSlot) -> String { + slot.bidders + .get("aps") + .and_then(|params| params.get("slotID")) + .and_then(Json::as_str) + .map(String::from) + .unwrap_or_else(|| slot.id.clone()) +} + +fn aps_slot_id_to_logical_slot_ids( + request: &AuctionRequest, +) -> Result<HashMap<String, String>, Report<TrustedServerError>> { + let mut slot_ids = HashMap::new(); + + for slot in &request.slots { + let aps_slot_id = aps_slot_id(slot); + if let Some(existing_slot_id) = slot_ids.insert(aps_slot_id.clone(), slot.id.clone()) { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "duplicate APS slotID '{aps_slot_id}' for logical slots \ + '{existing_slot_id}' and '{}'", + slot.id + ), + })); + } + } + + Ok(slot_ids) } impl AuctionProvider for ApsAuctionProvider { @@ -529,34 +632,19 @@ impl AuctionProvider for ApsAuctionProvider { fn parse_response( &self, - mut response: fastly::Response, + response: fastly::Response, response_time_ms: u64, ) -> Result<AuctionResponse, Report<TrustedServerError>> { - // Check status code - if !response.get_status().is_success() { - log::warn!("APS returned non-success status: {}", response.get_status()); - return Ok(AuctionResponse::error("aps", response_time_ms)); - } - - // Parse response body - let body_bytes = response.take_body_bytes(); - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { - message: "Failed to parse APS response JSON".to_string(), - })?; - - log::trace!("APS: received response: {:?}", response_json); - - // Transform to unified format - let auction_response = self.parse_aps_response(&response_json, response_time_ms); - - log::info!( - "APS returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); + self.parse_fastly_response(response, response_time_ms, None) + } - Ok(auction_response) + fn parse_response_for_request( + &self, + response: fastly::Response, + response_time_ms: u64, + request: &AuctionRequest, + ) -> Result<AuctionResponse, Report<TrustedServerError>> { + self.parse_fastly_response(response, response_time_ms, Some(request)) } fn supports_media_type(&self, media_type: &MediaType) -> bool { @@ -728,6 +816,91 @@ mod tests { assert_eq!(slot2.sizes[0], [300, 250]); } + #[test] + fn aps_request_uses_slot_level_aps_slot_id_when_present() { + let config = ApsConfig { + enabled: true, + pub_id: "5128".to_string(), + endpoint: "https://aax.amazon-adsystem.com/e/dtb/bid".to_string(), + timeout_ms: 800, + }; + let provider = ApsAuctionProvider::new(config); + let mut auction_request = create_test_auction_request(); + auction_request.slots[0].bidders.insert( + "aps".to_string(), + json!({ + "slotID": "aps-slot-atf-sidebar" + }), + ); + + let aps_request = provider.to_aps_request(&auction_request); + let slot = &aps_request.slots[0]; + + assert_eq!(slot.slot_id, "aps-slot-atf-sidebar"); + assert_eq!(slot.slot_name.as_deref(), Some("header-banner")); + } + + #[test] + fn aps_response_maps_external_slot_id_back_to_logical_slot_id() { + let config = ApsConfig { + enabled: true, + pub_id: "5128".to_string(), + endpoint: "https://aax.amazon-adsystem.com/e/dtb/bid".to_string(), + timeout_ms: 800, + }; + let provider = ApsAuctionProvider::new(config); + let mut auction_request = create_test_auction_request(); + auction_request.slots[0].bidders.insert( + "aps".to_string(), + json!({ + "slotID": "aps-slot-atf-sidebar" + }), + ); + let response_json = json!({ + "contextual": { + "slots": [{ + "slotID": "aps-slot-atf-sidebar", + "mediaType": "banner", + "size": "728x90", + "amznbid": "encoded-price-value", + "fif": "1" + }] + } + }); + + let auction_response = provider + .parse_aps_response_for_request(&response_json, 100, &auction_request) + .expect("should parse APS response"); + let bid = auction_response.bids.first().expect("should parse APS bid"); + + assert_eq!(bid.slot_id, "header-banner"); + } + + #[test] + fn aps_slot_id_map_rejects_duplicate_external_slot_ids() { + let mut auction_request = create_test_auction_request(); + for slot in &mut auction_request.slots { + slot.bidders.insert( + "aps".to_string(), + json!({ + "slotID": "duplicate-aps-slot" + }), + ); + } + + let error = aps_slot_id_to_logical_slot_ids(&auction_request) + .expect_err("should reject duplicate APS slot IDs"); + + assert!( + matches!( + error.current_context(), + TrustedServerError::Auction { message } + if message.contains("duplicate APS slotID") + ), + "should reject ambiguous APS slot ID mapping" + ); + } + #[test] fn test_aps_response_parsing_success() { let config = ApsConfig { @@ -904,7 +1077,7 @@ mod tests { }; let bid = provider - .parse_aps_slot(&aps_slot) + .parse_aps_slot(&aps_slot, &HashMap::new()) .expect("should parse slot"); assert_eq!(bid.slot_id, "test-slot"); @@ -1054,7 +1227,9 @@ mod tests { amznactt: Some("OPEN".to_string()), }; - let bid = provider.parse_aps_slot(&aps_slot).expect("should parse"); + let bid = provider + .parse_aps_slot(&aps_slot, &HashMap::new()) + .expect("should parse"); // Key assertions: // 1. creative should be None for APS bids diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 40bcf7f2c..b372e0c20 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -61,6 +61,14 @@ const SECUREPUBADS_HOST: &str = "securepubads.g.doubleclick.net"; /// Integration route prefix for all GPT proxy endpoints. const ROUTE_PREFIX: &str = "/integrations/gpt"; +const GPT_SHIM_INSTALL_SNIPPET: &str = + "<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>"; + +// IMPORTANT: This is the compiled/minified equivalent of installTsAdInit() in +// crates/js/lib/src/integrations/gpt/index.ts. Any logic change MUST be made +// in both places; rebuild the TypeScript bundle before updating this constant. +const TS_AD_INIT_SNIPPET: &str = r#"<script>(function(w){if(w.__tsAdInitInstalled){return;}w.__tsAdInit=function(){if(w.__tsAdInitInstalled){return false;}w.__tsAdInitInstalled=true;var googletag=w.googletag=w.googletag||{};googletag.cmd=googletag.cmd||[];googletag.cmd.push(function(){var slots=w.__ts_ad_slots||[];var rid=w.__ts_request_id;var bidsByAdId={};var bidsPromise=(rid&&w.fetch?w.fetch('/ts-bids?rid='+encodeURIComponent(rid),{credentials:'omit'}).then(function(r){return r.json();}).catch(function(){return {};}):Promise.resolve({}));var pubads=googletag.pubads&&googletag.pubads();if(!pubads||!googletag.defineSlot){return;}if(pubads.addEventListener){pubads.addEventListener('slotRenderEnded',function(e){var adid=e&&e.slot&&e.slot.getTargeting&&e.slot.getTargeting('hb_adid')[0];var bid=adid&&bidsByAdId[adid];if(bid&&bid.burl&&w.navigator&&w.navigator.sendBeacon){w.navigator.sendBeacon(bid.burl);delete bidsByAdId[adid];}});}var gptSlots=[];slots.forEach(function(s){var slot=googletag.defineSlot(s.gam_unit_path,s.formats||[],s.div_id);if(!slot){return;}if(slot.addService){slot.addService(pubads);}Object.keys(s.targeting||{}).forEach(function(k){slot.setTargeting(k,s.targeting[k]);});gptSlots.push({def:s,slot:slot});});if(pubads.disableInitialLoad){pubads.disableInitialLoad();}if(googletag.enableServices){googletag.enableServices();}gptSlots.forEach(function(pair){if(googletag.display){googletag.display(pair.def.div_id);}});bidsPromise.then(function(bids){gptSlots.forEach(function(pair){var bid=bids&&bids[pair.def.id];if(!bid){return;}['hb_pb','hb_bidder','hb_adid'].forEach(function(k){if(bid[k]!=null){pair.slot.setTargeting(k,String(bid[k]));}});if(bid.hb_adid){bidsByAdId[String(bid.hb_adid)]=bid;}});if(pubads.refresh){pubads.refresh(gptSlots.map(function(pair){return pair.slot;}));}});});return true;};w.__tsAdInit();})(window);</script>"#; + /// Configuration for the Google Publisher Tags integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct GptConfig { @@ -443,8 +451,8 @@ impl IntegrationHeadInjector for GptIntegration { // when it sees the pre-set flag, so this works regardless of whether // the inline bootstrap runs before or after the TSJS bundle. vec![ - "<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>" - .to_string(), + GPT_SHIM_INSTALL_SNIPPET.to_string(), + TS_AD_INIT_SNIPPET.to_string(), ] } } @@ -1020,11 +1028,58 @@ mod tests { let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should emit exactly one head insert"); - assert_eq!( - inserts[0], - "<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>", - "should set the enable flag and call the GPT shim activation function" + assert!( + inserts + .iter() + .any(|insert| insert == GPT_SHIM_INSTALL_SNIPPET), + "should keep the GPT shim activation snippet" + ); + } + + #[test] + fn head_injector_emits_ts_ad_init_bootstrap_without_inline_bids() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let combined = integration.head_inserts(&ctx).join(""); + + assert!( + combined.contains("__tsAdInit"), + "should emit the server-side GPT bootstrap" + ); + assert!( + combined.contains("/ts-bids"), + "should fetch bids from the bid endpoint" + ); + assert!( + combined.contains("__ts_request_id"), + "should read the request ID global" + ); + assert!( + combined.contains("bidsPromise"), + "should create a bid fetch promise" + ); + assert!( + combined.contains("disableInitialLoad"), + "should disable GPT initial load so display does not request ads before bid targeting" + ); + assert!( + combined.contains("slotRenderEnded"), + "should listen for rendered GPT slots" + ); + assert!( + combined.contains("sendBeacon"), + "should fire billing notifications via sendBeacon" + ); + assert!( + !combined.contains("__ts_bids"), + "should not read or write inline bid globals" ); } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 6dfd1ca1a..73f8d96b9 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -119,6 +119,10 @@ pub struct PrebidIntegrationConfig { /// - `both` — consent in both cookies and body (default) #[serde(default)] pub consent_forwarding: ConsentForwardingMode, + /// Whether the edge should fire Prebid win-notification URLs after + /// server-side template auction completion. + #[serde(default = "default_fire_nurl_at_edge")] + pub fire_nurl_at_edge: bool, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -139,6 +143,10 @@ fn default_enabled() -> bool { true } +fn default_fire_nurl_at_edge() -> bool { + true +} + /// Default suffixes that identify Prebid scripts const PREBID_SCRIPT_SUFFIXES: &[&str] = &[ "/prebid.js", @@ -975,6 +983,12 @@ impl PrebidAuctionProvider { .collect() }); + let ad_id = bid_obj + .get("adid") + .or_else(|| bid_obj.get("id")) + .and_then(|v| v.as_str()) + .map(String::from); + Ok(AuctionBid { slot_id, price: Some(price), // Prebid provides decoded prices @@ -986,6 +1000,7 @@ impl PrebidAuctionProvider { height, nurl, burl, + ad_id, metadata: std::collections::HashMap::new(), }) } @@ -1258,9 +1273,20 @@ mod tests { client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::new(), consent_forwarding: ConsentForwardingMode::Both, + fire_nurl_at_edge: true, } } + #[test] + fn prebid_fire_nurl_at_edge_defaults_to_true() { + let config = base_config(); + + assert!( + config.fire_nurl_at_edge, + "should default to edge nurl firing" + ); + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), @@ -2971,6 +2997,50 @@ fixed_bottom = {placementId = "_s2sBottom"} ); } + #[test] + fn parse_openrtb_response_uses_prebid_adid_for_bid_ad_id() { + let provider = PrebidAuctionProvider::new(base_config()); + + let response_json = json!({ + "seatbid": [{ + "seat": "kargo", + "bid": [{ + "impid": "slot-1", + "price": 2.50, + "adm": "<div>ad</div>", + "adid": "prebid-ad-id-abc" + }] + }] + }); + + let auction_response = provider.parse_openrtb_response(&response_json, 25); + let bid = auction_response.bids.first().expect("should parse bid"); + + assert_eq!(bid.ad_id.as_deref(), Some("prebid-ad-id-abc")); + } + + #[test] + fn parse_openrtb_response_falls_back_to_bid_id_for_bid_ad_id() { + let provider = PrebidAuctionProvider::new(base_config()); + + let response_json = json!({ + "seatbid": [{ + "seat": "kargo", + "bid": [{ + "id": "openrtb-bid-id-abc", + "impid": "slot-1", + "price": 2.50, + "adm": "<div>ad</div>" + }] + }] + }); + + let auction_response = provider.parse_openrtb_response(&response_json, 25); + let bid = auction_response.bids.first().expect("should parse bid"); + + assert_eq!(bid.ad_id.as_deref(), Some("openrtb-bid-id-abc")); + } + #[test] fn enrich_response_metadata_includes_debug_fields_when_enabled() { let mut config = base_config(); diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 44fa108dd..b79a51a59 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,11 +35,13 @@ pub mod auction; pub mod auction_config_types; pub mod auth; pub mod backend; +pub mod bid_cache; pub mod consent; 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 +52,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/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index b6efe1b4e..bbc9acd34 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -148,6 +148,15 @@ pub struct PlatformSelectResult { pub remaining: Vec<PlatformPendingRequest>, } +/// Result of non-blocking polling for one in-flight request. +#[derive(Debug)] +pub enum PlatformPollResult { + /// The request is still pending and can be polled again later. + Pending(PlatformPendingRequest), + /// The request completed or failed. + Ready(Result<PlatformResponse, Report<PlatformError>>), +} + /// Outbound HTTP client abstraction. /// /// Supports both single-request sends ([`Self::send`]) and async fan-out @@ -183,6 +192,20 @@ pub trait PlatformHttpClient: Send + Sync { request: PlatformHttpRequest, ) -> Result<PlatformPendingRequest, Report<PlatformError>>; + /// Start an upstream request without waiting for it to complete from a + /// synchronous call site. + /// + /// # Errors + /// + /// Returns `PlatformError::Unsupported` when the platform adapter does not + /// provide a synchronous fire-and-forget start primitive. + fn send_async_now( + &self, + _request: PlatformHttpRequest, + ) -> Result<PlatformPendingRequest, Report<PlatformError>> { + Err(Report::new(PlatformError::Unsupported)) + } + /// Wait for one of the in-flight requests to complete. /// /// # Errors @@ -194,6 +217,33 @@ pub trait PlatformHttpClient: Send + Sync { pending_requests: Vec<PlatformPendingRequest>, ) -> Result<PlatformSelectResult, Report<PlatformError>>; + /// Poll a single in-flight request without blocking. + /// + /// # Errors + /// + /// Returns `PlatformError::Unsupported` when the platform adapter does not + /// provide non-blocking polling. + async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result<PlatformPollResult, Report<PlatformError>> { + self.poll_now(pending) + } + + /// Poll a single in-flight request without blocking from a synchronous call + /// site. + /// + /// # Errors + /// + /// Returns `PlatformError::Unsupported` when the platform adapter does not + /// provide non-blocking polling. + fn poll_now( + &self, + _pending: PlatformPendingRequest, + ) -> Result<PlatformPollResult, Report<PlatformError>> { + Err(Report::new(PlatformError::Unsupported)) + } + /// Wait for a single in-flight request to complete. /// /// This is a convenience wrapper around [`select`](Self::select) for the diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 6662e8374..61c3ae29e 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -42,8 +42,8 @@ mod types; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; pub use http::{ - PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, + PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformPollResult, + PlatformResponse, PlatformSelectResult, }; pub use kv::UnavailableKvStore; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 63628ad68..a241f7b96 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -9,8 +9,9 @@ use rand::rngs::OsRng; use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, + PlatformPollResult, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + RuntimeServices, StoreId, StoreName, }; use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; @@ -297,6 +298,13 @@ impl PlatformHttpClient for StubHttpClient { async fn send_async( &self, request: PlatformHttpRequest, + ) -> Result<PlatformPendingRequest, Report<PlatformError>> { + self.send_async_now(request) + } + + fn send_async_now( + &self, + request: PlatformHttpRequest, ) -> Result<PlatformPendingRequest, Report<PlatformError>> { let backend_name = request.backend_name.clone(); self.calls @@ -354,6 +362,33 @@ impl PlatformHttpClient for StubHttpClient { remaining: pending_requests, }) } + + async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result<PlatformPollResult, Report<PlatformError>> { + self.poll_now(pending) + } + + fn poll_now( + &self, + pending: PlatformPendingRequest, + ) -> Result<PlatformPollResult, Report<PlatformError>> { + let stub = pending.downcast::<StubPendingResponse>().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("unexpected inner type in StubHttpClient::poll") + })?; + + let edge_response = edgezero_core::http::response_builder() + .status(stub.status) + .body(edgezero_core::body::Body::from(stub.body)) + .change_context(PlatformError::HttpClient)?; + + Ok(PlatformPollResult::Ready(Ok(PlatformResponse::new( + edge_response, + ) + .with_backend_name(stub.backend_name)))) + } } pub(crate) struct NoopGeo; @@ -596,6 +631,38 @@ mod tests { ); } + #[test] + fn stub_http_client_poll_returns_ready_response() { + let stub = StubHttpClient::new(); + stub.push_response(202, b"poll-ready".to_vec()); + let req = PlatformHttpRequest::new( + request_builder() + .method("GET") + .uri("https://example.com/bid") + .body(Body::empty()) + .expect("should build request"), + "backend-poll", + ); + let pending = + futures::executor::block_on(stub.send_async(req)).expect("should start request"); + + let result = futures::executor::block_on(stub.poll(pending)).expect("should poll request"); + + let PlatformPollResult::Ready(Ok(response)) = result else { + panic!("should return ready response"); + }; + assert_eq!( + response.backend_name.as_deref(), + Some("backend-poll"), + "should preserve backend correlation" + ); + assert_eq!( + response.response.status(), + 202, + "should preserve response status" + ); + } + #[test] fn stub_backend_returns_fixed_name() { let stub = StubBackend; diff --git a/crates/trusted-server-core/src/price_bucket.rs b/crates/trusted-server-core/src/price_bucket.rs new file mode 100644 index 000000000..b35493f66 --- /dev/null +++ b/crates/trusted-server-core/src/price_bucket.rs @@ -0,0 +1,103 @@ +//! Prebid price granularity bucketing. + +use serde::{Deserialize, Serialize}; + +/// Prebid price granularity setting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PriceGranularity { + /// Low granularity. + Low, + /// Medium granularity. + Medium, + /// Dense granularity. + #[default] + Dense, + /// High granularity. + High, + /// Auto granularity, treated as dense for Phase 1. + Auto, +} + +impl PriceGranularity { + /// Returns [`PriceGranularity::Dense`]; used as a serde default function pointer. + #[must_use] + pub const fn dense() -> Self { + Self::Dense + } +} + +/// Convert raw CPM to the `hb_pb` price bucket string. +#[must_use] +pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { + if cpm <= 0.0 { + return "0.00".to_string(); + } + + match granularity { + PriceGranularity::Low => bucket(cpm, 5.0, 0.50), + PriceGranularity::Medium => bucket(cpm, 20.0, 0.10), + PriceGranularity::High => bucket(cpm, 20.0, 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 bucket(cpm, 20.0, 0.50); + } + if cpm >= 3.0 { + return bucket(cpm, 8.0, 0.05); + } + bucket(cpm, 3.0, 0.01) +} + +fn bucket(cpm: f64, cap: f64, increment: f64) -> String { + let capped = cpm.min(cap); + format!("{:.2}", ((capped / increment) + 1e-9).floor() * increment) +} + +#[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.015, PriceGranularity::Dense), "0.01"); + 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.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.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 built_in_granularities_cap_correctly() { + assert_eq!(price_bucket(5.01, PriceGranularity::Low), "5.00"); + assert_eq!(price_bucket(20.5, PriceGranularity::Medium), "20.00"); + assert_eq!(price_bucket(20.5, PriceGranularity::High), "20.00"); + assert_eq!(price_bucket(50.0, PriceGranularity::Dense), "20.00"); + } + + #[test] + fn auto_routes_to_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..463f290e2 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -11,21 +11,34 @@ //! `fastly::Request`/`Response`/`Body` migrations. It is not a //! content-rewriting concern. -use std::io::Write; +use std::collections::HashMap; +use std::io::{self, Write}; +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::request_builder; use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; +use crate::auction::orchestrator::{AuctionOrchestrator, PendingAuction, PendingAuctionPoll}; +use crate::auction::types::{ + AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, +}; use crate::backend::BackendConfig; +use crate::bid_cache::{BidCache, BidMap}; use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_TS_EC, HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; use crate::cookies::{expire_ec_cookie, handle_request_cookies, set_ec_cookie}; -use crate::edge_cookie::get_or_generate_ec_id; +use crate::creative_opportunities::{ + match_slots, CreativeOpportunitiesConfig, CreativeOpportunitiesFile, CreativeOpportunitySlot, +}; +use crate::edge_cookie::{generate_ec_id, get_or_generate_ec_id}; use crate::error::TrustedServerError; +use crate::geo::GeoInfo; use crate::http_util::{serve_static_with_etag, RequestInfo}; +use crate::integrations::prebid::PrebidIntegrationConfig; use crate::integrations::IntegrationRegistry; -use crate::platform::RuntimeServices; +use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -172,6 +185,228 @@ fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { .find(|&id| id == stem) } +/// Build the head script that exposes server-side ad slot metadata. +#[must_use] +pub(crate) fn build_head_globals_script( + matched_slots: &[&CreativeOpportunitySlot], + request_id: &str, + co_config: &CreativeOpportunitiesConfig, +) -> String { + #[derive(serde::Serialize)] + struct HeadAdSlot<'a> { + id: &'a str, + gam_unit_path: String, + div_id: String, + formats: Vec<[u32; 2]>, + targeting: &'a std::collections::HashMap<String, String>, + } + + let slots = matched_slots + .iter() + .map(|slot| HeadAdSlot { + id: &slot.id, + gam_unit_path: slot.resolved_gam_unit_path(co_config), + div_id: slot.resolved_div_id(), + formats: slot + .formats + .iter() + .map(|format| [format.width, format.height]) + .collect(), + targeting: &slot.targeting, + }) + .collect::<Vec<_>>(); + + let slots_json = serde_json::to_string(&slots).expect("should serialize ad slots"); + let request_id_json = serde_json::to_string(request_id).expect("should serialize request ID"); + let escaped_slots_json = html_escape_for_script(&slots_json); + let escaped_request_id_json = html_escape_for_script(&request_id_json); + + format!( + "<script>window.__ts_ad_slots=JSON.parse(\"{escaped_slots_json}\");window.__ts_request_id=JSON.parse(\"{escaped_request_id_json}\");</script>" + ) +} + +/// Escape JSON so it can be embedded in a JavaScript string inside an HTML script. +#[must_use] +pub(crate) fn html_escape_for_script(json: &str) -> String { + let mut escaped = String::with_capacity(json.len()); + for ch in json.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '<' => escaped.push_str("\\u003C"), + '>' => escaped.push_str("\\u003E"), + '&' => escaped.push_str("\\u0026"), + '\u{2028}' => escaped.push_str("\\u2028"), + '\u{2029}' => escaped.push_str("\\u2029"), + _ => escaped.push(ch), + } + } + escaped +} + +#[must_use] +pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap<String, crate::auction::types::Bid>, + price_granularity: crate::price_bucket::PriceGranularity, +) -> crate::bid_cache::BidMap { + winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + let cpm = bid.price?; + Some(( + slot_id.clone(), + serde_json::json!({ + "hb_pb": crate::price_bucket::price_bucket(cpm, price_granularity), + "hb_bidder": bid.bidder.as_str(), + "hb_adid": bid.ad_id.as_deref().unwrap_or(""), + "burl": bid.burl.as_deref(), + }), + )) + }) + .collect() +} + +fn server_side_auction_allowed(consent_context: &crate::consent::ConsentContext) -> bool { + consent_context + .tcf + .as_ref() + .is_some_and(|tcf| tcf.has_purpose_consent(1)) +} + +fn apply_server_side_ad_cache_policy(response: &mut Response, slots_matched: bool) { + if slots_matched { + response.set_header(header::CACHE_CONTROL, "private, no-store"); + } +} + +fn prebid_fire_nurl_at_edge(settings: &Settings) -> bool { + match settings.integration_config::<PrebidIntegrationConfig>("prebid") { + Ok(Some(config)) => config.fire_nurl_at_edge, + Ok(None) => true, + Err(error) => { + log::warn!("Failed to read Prebid fire_nurl_at_edge setting: {error:?}"); + true + } + } +} + +fn fire_prebid_nurls_at_edge( + services: &RuntimeServices, + winning_bids: &HashMap<String, crate::auction::types::Bid>, + certificate_check: bool, +) { + for bid in winning_bids.values() { + let Some(nurl) = bid.nurl.as_deref() else { + continue; + }; + + let backend_name = match BackendConfig::from_url(nurl, certificate_check) { + Ok(name) => name, + Err(error) => { + log::warn!( + "Failed to build backend for Prebid nurl '{}' from bidder '{}': {error:?}", + nurl, + bid.bidder + ); + continue; + } + }; + + let request = match request_builder() + .method("GET") + .uri(nurl) + .body(EdgeBody::empty()) + { + Ok(request) => request, + Err(error) => { + log::warn!( + "Failed to build Prebid nurl request '{}' from bidder '{}': {error}", + nurl, + bid.bidder + ); + continue; + } + }; + + if let Err(error) = services + .http_client() + .send_async_now(PlatformHttpRequest::new(request, backend_name)) + { + log::warn!( + "Failed to dispatch Prebid nurl '{}' from bidder '{}': {error:?}", + nurl, + bid.bidder + ); + } + } +} + +pub(crate) struct ServerSideAuctionRequestParams<'a> { + request_id: &'a str, + matched_slots: &'a [&'a CreativeOpportunitySlot], + settings: &'a Settings, + services: &'a RuntimeServices, + req: &'a Request, + consent_context: &'a crate::consent::ConsentContext, + ec_id: &'a str, + geo: Option<GeoInfo>, +} + +fn build_server_side_auction_request( + params: ServerSideAuctionRequestParams<'_>, +) -> Result<AuctionRequest, Report<TrustedServerError>> { + let page_url = params.req.get_url().to_string(); + let fresh_id = generate_ec_id(params.settings, params.services).change_context( + TrustedServerError::Auction { + message: "Failed to generate fresh EC ID for server-side ad auction".to_string(), + }, + )?; + + Ok(AuctionRequest { + id: params.request_id.to_string(), + slots: params + .matched_slots + .iter() + .map(|slot| slot.to_ad_slot()) + .collect(), + publisher: PublisherInfo { + domain: params.settings.publisher.domain.clone(), + page_url: Some(page_url.clone()), + }, + user: UserInfo { + id: params.ec_id.to_string(), + fresh_id, + consent: Some(params.consent_context.clone()), + }, + device: Some(DeviceInfo { + user_agent: params + .req + .get_header_str(header::USER_AGENT) + .map(str::to_string), + ip: params + .services + .client_info + .client_ip + .map(|ip| ip.to_string()), + geo: params.geo, + }), + site: Some(SiteInfo { + domain: params.settings.publisher.domain.clone(), + page: page_url, + }), + context: HashMap::new(), + }) +} + +fn clone_request_metadata_for_auction(req: &Request) -> Request { + let mut cloned = Request::new(req.get_method().clone(), req.get_url().clone()); + for (name, value) in req.get_headers() { + cloned.append_header(name, value.clone()); + } + cloned +} + /// Parameters for processing response streaming struct ProcessResponseParams<'a> { content_encoding: &'a str, @@ -182,6 +417,7 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, } /// Process response body through the streaming pipeline. @@ -224,6 +460,7 @@ fn process_response_streaming<W: Write>( params.request_scheme, params.settings, params.integration_registry, + params.ad_slots_script, )?; StreamingPipeline::new(config, processor).process(body, output)?; } else if is_rsc_flight { @@ -254,16 +491,18 @@ fn create_html_stream_processor( request_scheme: &str, settings: &Settings, integration_registry: &IntegrationRegistry, + ad_slots_script: Option<&str>, ) -> Result<impl StreamProcessor, Report<TrustedServerError>> { use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - let config = HtmlProcessorConfig::from_settings( + let mut config = HtmlProcessorConfig::from_settings( settings, integration_registry, origin_host, request_host, request_scheme, ); + config.ad_slots_script = ad_slots_script.map(str::to_string); Ok(create_html_processor(config)) } @@ -290,6 +529,8 @@ pub enum PublisherResponse { body: Body, /// Parameters for `process_response_streaming`. params: OwnedProcessResponseParams, + /// Optional server-side ad auction advanced while the body streams. + server_side_auction: Option<Box<ServerSideAuctionStream>>, }, /// Non-processable 2xx response (images, fonts, video). The adapter must /// reattach the body via `response.set_body(body)` before returning. @@ -392,6 +633,165 @@ pub struct OwnedProcessResponseParams { pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, + pub(crate) ad_slots_script: Option<String>, +} + +/// Server-side ad auction state advanced during publisher response streaming. +pub struct ServerSideAuctionStream { + request_id: String, + pending_auction: PendingAuction, + price_granularity: crate::price_bucket::PriceGranularity, + fire_nurl_at_edge: bool, + certificate_check: bool, + completed: bool, +} + +impl ServerSideAuctionStream { + /// Create stream-owned server-side auction state. + #[must_use] + pub fn new( + request_id: String, + pending_auction: PendingAuction, + price_granularity: crate::price_bucket::PriceGranularity, + fire_nurl_at_edge: bool, + certificate_check: bool, + ) -> Self { + Self { + request_id, + pending_auction, + price_granularity, + fire_nurl_at_edge, + certificate_check, + completed: false, + } + } + + fn poll_once(&mut self, services: &RuntimeServices, bid_cache: &dyn BidCache) { + if self.completed { + return; + } + + match self.pending_auction.poll_once_now(services) { + Ok(PendingAuctionPoll::Pending) => {} + Ok(PendingAuctionPoll::Complete(result)) => { + self.write_result(services, bid_cache, &result); + } + Err(error) => { + log::warn!("Server-side ad auction poll failed: {error:?}"); + self.write_empty(bid_cache); + } + } + } + + fn complete_with_collected_bids( + &mut self, + services: &RuntimeServices, + bid_cache: &dyn BidCache, + ) { + if self.completed { + return; + } + + let result = self.pending_auction.finish_due_to_deadline(); + self.write_result(services, bid_cache, &result); + } + + fn drain_after_stream(&mut self, services: &RuntimeServices, bid_cache: &dyn BidCache) { + while !self.completed { + match self.pending_auction.poll_once_now(services) { + Ok(PendingAuctionPoll::Pending) => { + // poll_once_now already checks the deadline internally via finish_poll_round; + // no sleep — wasm32-wasip1 has no scheduler to yield to. + } + Ok(PendingAuctionPoll::Complete(result)) => { + self.write_result(services, bid_cache, &result); + } + Err(error) => { + log::warn!("Server-side ad auction drain failed: {error:?}"); + self.write_empty(bid_cache); + } + } + } + } + + fn write_result( + &mut self, + services: &RuntimeServices, + bid_cache: &dyn BidCache, + result: &crate::auction::orchestrator::OrchestrationResult, + ) { + let bids = build_bid_map(&result.winning_bids, self.price_granularity); + let wrote_bids = self.write_bids(bid_cache, bids); + if wrote_bids && self.fire_nurl_at_edge { + fire_prebid_nurls_at_edge(services, &result.winning_bids, self.certificate_check); + } + } + + fn write_empty(&mut self, bid_cache: &dyn BidCache) { + self.write_bids(bid_cache, BidMap::new()); + } + + fn write_bids(&mut self, bid_cache: &dyn BidCache, bids: BidMap) -> bool { + let result = bid_cache.put(&self.request_id, bids); + let wrote_bids = result.is_ok(); + if let Err(error) = result { + log::warn!( + "Failed to write server-side ad bids for request '{}': {error:?}", + self.request_id + ); + } + self.completed = true; + wrote_bids + } +} + +struct AuctionPollingWriter<'a, W: Write> { + inner: &'a mut W, + services: &'a RuntimeServices, + bid_cache: &'a dyn BidCache, + server_side_auction: &'a mut ServerSideAuctionStream, +} + +impl<W: Write> AuctionPollingWriter<'_, W> { + fn poll_auction(&mut self) { + self.server_side_auction + .poll_once(self.services, self.bid_cache); + } + + fn finish_auction(&mut self) { + self.server_side_auction + .drain_after_stream(self.services, self.bid_cache); + } +} + +impl<W: Write> Write for AuctionPollingWriter<'_, W> { + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + let bytes_written = self.inner.write(buf)?; + self.poll_auction(); + Ok(bytes_written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush()?; + self.poll_auction(); + Ok(()) + } +} + +#[derive(Clone, Copy)] +enum AuctionFinishMode { + DrainAfterStream, + CompleteCollected, +} + +/// Server-side auction dependencies used while streaming a publisher body. +pub struct ServerSideAuctionStreamContext<'a> { + /// Runtime services used to poll platform HTTP requests. + pub services: &'a RuntimeServices, + /// Request-scoped bid cache rendezvous. + pub bid_cache: &'a dyn BidCache, + /// Auction state advanced during streaming. + pub server_side_auction: &'a mut ServerSideAuctionStream, } /// Stream the publisher response body through the processing pipeline. @@ -420,10 +820,67 @@ pub fn stream_publisher_body<W: Write>( settings, content_type: ¶ms.content_type, integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), }; process_response_streaming(body, output, &borrowed) } +fn process_response_streaming_with_ad_auction<W: Write>( + body: Body, + output: &mut W, + params: &ProcessResponseParams<'_>, + auction_context: &mut ServerSideAuctionStreamContext<'_>, + finish_mode: AuctionFinishMode, +) -> Result<(), Report<TrustedServerError>> { + let mut polling_output = AuctionPollingWriter { + inner: output, + services: auction_context.services, + bid_cache: auction_context.bid_cache, + server_side_auction: &mut *auction_context.server_side_auction, + }; + let result = process_response_streaming(body, &mut polling_output, params); + match finish_mode { + AuctionFinishMode::DrainAfterStream => polling_output.finish_auction(), + AuctionFinishMode::CompleteCollected => polling_output + .server_side_auction + .complete_with_collected_bids(auction_context.services, auction_context.bid_cache), + } + result +} + +/// Stream the publisher response body while advancing a server-side ad auction. +/// +/// # Errors +/// +/// Returns an error if body processing fails mid-stream. +pub fn stream_publisher_body_with_ad_auction<W: Write>( + body: Body, + output: &mut W, + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, + auction_context: &mut ServerSideAuctionStreamContext<'_>, +) -> Result<(), Report<TrustedServerError>> { + let borrowed = ProcessResponseParams { + content_encoding: ¶ms.content_encoding, + origin_host: ¶ms.origin_host, + origin_url: ¶ms.origin_url, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + content_type: ¶ms.content_type, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + }; + process_response_streaming_with_ad_auction( + body, + output, + &borrowed, + auction_context, + AuctionFinishMode::DrainAfterStream, + ) +} + /// Proxies requests to the publisher's origin server. /// /// Returns a [`PublisherResponse`] indicating how the response should be sent: @@ -445,6 +902,9 @@ pub fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, services: &RuntimeServices, + orchestrator: &AuctionOrchestrator, + slots_file: &CreativeOpportunitiesFile, + bid_cache: &dyn BidCache, mut req: Request, ) -> Result<PublisherResponse, Report<TrustedServerError>> { log::debug!("Proxying request to publisher_origin"); @@ -509,6 +969,17 @@ pub fn handle_publisher_request( let ec_allowed = allows_ec_creation(&consent_context); log::debug!("Proxy ec_allowed: {}", ec_allowed); + let request_path = req.get_path().to_string(); + let auction_context_request = clone_request_metadata_for_auction(&req); + let creative_opportunities = settings.creative_opportunities.as_ref(); + let matched_slots = creative_opportunities + .map(|_| match_slots(&slots_file.slots, &request_path)) + .unwrap_or_default(); + let slots_matched = !matched_slots.is_empty(); + let auction_timeout_ms = creative_opportunities + .and_then(|config| config.auction_timeout_ms) + .unwrap_or(settings.auction.timeout_ms); + let backend_name = BackendConfig::from_url( &settings.publisher.origin_url, settings.proxy.certificate_check, @@ -524,8 +995,95 @@ pub fn handle_publisher_request( restrict_accept_encoding(&mut req); req.set_header("host", &origin_host); - let mut response = req - .send(&backend_name) + let pending_origin = + req.send_async(&backend_name) + .change_context(TrustedServerError::Proxy { + message: "Failed to dispatch publisher origin request".to_string(), + })?; + + let mut ad_slots_script = None; + let mut server_side_auction = None; + if let Some(co_config) = creative_opportunities { + if slots_matched && server_side_auction_allowed(&consent_context) { + let request_id = uuid::Uuid::new_v4().to_string(); + let auction_deadline = crate::bid_cache::AuctionDeadline::from_timeout( + std::time::Duration::from_millis(u64::from(auction_timeout_ms)), + ); + + match bid_cache.mark_pending(&request_id, auction_deadline) { + Ok(()) => { + match build_server_side_auction_request(ServerSideAuctionRequestParams { + request_id: &request_id, + matched_slots: &matched_slots, + settings, + services, + req: &auction_context_request, + consent_context: &consent_context, + ec_id: &ec_id, + geo: geo.clone(), + }) { + Ok(auction_request) => { + let context = AuctionContext { + settings, + request: &auction_context_request, + client_info: services.client_info(), + timeout_ms: auction_timeout_ms, + provider_responses: None, + services, + }; + + match orchestrator.start_server_side_auction(auction_request, &context) + { + Ok(pending_auction) => { + ad_slots_script = Some(build_head_globals_script( + &matched_slots, + &request_id, + co_config, + )); + server_side_auction = Some(ServerSideAuctionStream::new( + request_id, + pending_auction, + co_config.price_granularity, + prebid_fire_nurl_at_edge(settings), + settings.proxy.certificate_check, + )); + } + Err(error) => { + log::warn!("Failed to start server-side ad auction: {error:?}"); + if let Err(cache_error) = + bid_cache.put(&request_id, BidMap::new()) + { + log::warn!( + "Failed to complete failed server-side ad auction request '{}': {cache_error:?}", + request_id + ); + } + } + } + } + Err(error) => { + log::warn!("Failed to build server-side ad auction request: {error:?}"); + if let Err(cache_error) = bid_cache.put(&request_id, BidMap::new()) { + log::warn!( + "Failed to complete failed server-side ad auction request '{}': {cache_error:?}", + request_id + ); + } + } + } + } + Err(error) => { + log::warn!( + "Failed to mark server-side ad auction pending for path '{}': {error:?}", + request_path + ); + } + } + } + } + + let mut response = pending_origin + .wait() .change_context(TrustedServerError::Proxy { message: "Failed to proxy request to origin".to_string(), })?; @@ -546,6 +1104,7 @@ pub fn handle_publisher_request( existing_ec_cookie.as_deref(), &consent_context, ); + apply_server_side_ad_cache_policy(&mut response, slots_matched); let content_type = response .get_header(header::CONTENT_TYPE) @@ -576,6 +1135,9 @@ pub fn handle_publisher_request( content_type, status, ); + if let Some(mut auction) = server_side_auction { + auction.complete_with_collected_bids(services, bid_cache); + } let body = response.take_body(); Ok(PublisherResponse::PassThrough { response, body }) } @@ -602,6 +1164,9 @@ pub fn handle_publisher_request( status, ); } + if let Some(mut auction) = server_side_auction { + auction.complete_with_collected_bids(services, bid_cache); + } Ok(PublisherResponse::Buffered(response)) } ResponseRoute::Stream => { @@ -623,7 +1188,9 @@ pub fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + ad_slots_script: ad_slots_script.clone(), }, + server_side_auction: server_side_auction.map(Box::new), }) } ResponseRoute::BufferedProcessed => { @@ -642,9 +1209,25 @@ pub fn handle_publisher_request( settings, content_type: &content_type, integration_registry, + ad_slots_script: ad_slots_script.as_deref(), }; let mut output = Vec::new(); - process_response_streaming(body, &mut output, ¶ms)?; + if let Some(mut auction) = server_side_auction { + let mut auction_context = ServerSideAuctionStreamContext { + services, + bid_cache, + server_side_auction: &mut auction, + }; + process_response_streaming_with_ad_auction( + body, + &mut output, + ¶ms, + &mut auction_context, + AuctionFinishMode::CompleteCollected, + )?; + } else { + process_response_streaming(body, &mut output, ¶ms)?; + } response.set_header(header::CONTENT_LENGTH, output.len().to_string()); response.set_body(Body::from(output)); @@ -717,12 +1300,115 @@ fn apply_ec_headers( } #[cfg(test)] -mod tests { +mod creative_opportunities_tests { use super::*; + use crate::auction::orchestrator::{OrchestrationResult, PendingAuction}; + use crate::auction::types::{ + AuctionRequest, AuctionResponse, Bid, MediaType, PublisherInfo, UserInfo, + }; + use crate::bid_cache::{AuctionDeadline, CacheResult, InMemoryBidCache}; + use crate::consent::{ConsentContext, TcfConsent}; + use crate::creative_opportunities::{ + CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, + SlotProviders, + }; use crate::integrations::IntegrationRegistry; - use crate::platform::test_support::noop_services; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; + use crate::price_bucket::PriceGranularity; use crate::test_support::tests::create_test_settings; use fastly::http::{header, Method, StatusCode}; + use serde_json::json; + use std::collections::HashMap; + use std::sync::Arc; + + fn make_creative_slot(id: &str) -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: id.to_string(), + gam_unit_path: Some(format!("/21765378893/{id}")), + div_id: Some(format!("div-{id}")), + page_patterns: vec!["/news/**".to_string()], + formats: vec![ + CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + }, + CreativeOpportunityFormat { + width: 728, + height: 90, + media_type: MediaType::Banner, + }, + ], + floor_price: Some(1.25), + targeting: HashMap::from([("pos".to_string(), "atf".to_string())]), + providers: SlotProviders::default(), + } + } + + fn make_creative_config() -> CreativeOpportunitiesConfig { + CreativeOpportunitiesConfig { + gam_network_id: "21765378893".to_string(), + auction_timeout_ms: Some(500), + price_granularity: PriceGranularity::Dense, + } + } + + fn make_bid(slot_id: &str, price: Option<f64>, ad_id: Option<&str>) -> Bid { + Bid { + slot_id: slot_id.to_string(), + price, + currency: "USD".to_string(), + creative: Some("<div>ad</div>".to_string()), + adomain: Some(vec!["advertiser.example".to_string()]), + bidder: "rubicon".to_string(), + width: 300, + height: 250, + nurl: Some("https://bidder.example/win".to_string()), + burl: Some("https://bidder.example/bill".to_string()), + ad_id: ad_id.map(str::to_string), + metadata: HashMap::new(), + } + } + + fn make_auction_request() -> AuctionRequest { + AuctionRequest { + id: "auction-1".to_string(), + slots: Vec::new(), + publisher: PublisherInfo { + domain: "test-publisher.com".to_string(), + page_url: Some("https://test-publisher.com/news/story".to_string()), + }, + user: UserInfo { + id: "ec-id".to_string(), + fresh_id: "fresh-id".to_string(), + consent: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn make_tcf_with_purpose_1(consented: bool) -> TcfConsent { + TcfConsent { + version: 2, + cmp_id: 1, + cmp_version: 1, + consent_screen: 1, + consent_language: "EN".to_string(), + vendor_list_version: 42, + tcf_policy_version: 4, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![consented, true, true], + purpose_legitimate_interests: vec![false; 3], + vendor_consents: vec![], + vendor_legitimate_interests: vec![], + special_feature_opt_ins: vec![], + } + } #[test] fn test_content_type_detection() { @@ -751,6 +1437,255 @@ mod tests { } } + #[test] + fn build_head_globals_script_sets_slots_and_request_id_only() { + let slot = make_creative_slot("atf_sidebar"); + let slots = vec![&slot]; + + let script = build_head_globals_script(&slots, "req-123", &make_creative_config()); + + assert!( + script.contains("window.__ts_ad_slots"), + "should set ad slots global" + ); + assert!( + script.contains("window.__ts_request_id"), + "should set request ID global" + ); + assert!( + script.contains("JSON.parse(\""), + "should parse escaped JSON from a string literal" + ); + assert!( + script.contains("\\\"id\\\":\\\"atf_sidebar\\\""), + "should include slot ID in escaped JSON" + ); + assert!( + script.contains("\\\"gam_unit_path\\\":\\\"/21765378893/atf_sidebar\\\""), + "should include resolved GAM unit path" + ); + assert!( + script.contains("\\\"formats\\\":[[300,250],[728,90]]"), + "should include slot formats" + ); + assert!( + script.contains("\\\"targeting\\\":{\\\"pos\\\":\\\"atf\\\"}"), + "should include static targeting" + ); + assert!( + script.contains("JSON.parse(\"\\\"req-123\\\"\")"), + "should set request ID through JSON.parse" + ); + assert!( + !script.contains("window.__ts_bids"), + "head globals must not inject bid data" + ); + } + + #[test] + fn html_escape_for_script_prevents_raw_script_breakout() { + let unsafe_json = serde_json::to_string(&json!({ + "slot": "</script><script>alert(\"x\")</script>", + "angle": "<tag>", + "ampersand": "a&b", + "line": "first\u{2028}second\u{2029}third", + })) + .expect("should serialize unsafe JSON fixture"); + + let escaped = html_escape_for_script(&unsafe_json); + + assert!( + !escaped.contains("</script>"), + "escaped JSON string should not contain a raw script end tag" + ); + assert!( + !escaped.contains('<') && !escaped.contains('>'), + "less-than and greater-than should be escaped for HTML script safety" + ); + assert!( + !escaped.contains('&'), + "ampersands should be escaped for HTML script safety" + ); + assert!( + !escaped.contains('\u{2028}') && !escaped.contains('\u{2029}'), + "line separators should be escaped for JavaScript string safety" + ); + assert!( + escaped.contains("\\u003C/script\\u003E"), + "less-than and greater-than should be escaped for HTML script safety" + ); + assert!( + escaped.contains("\\u0026"), + "ampersands should be escaped for HTML safety" + ); + assert!( + escaped.contains("\\u2028") && escaped.contains("\\u2029"), + "line separators should be escaped for JavaScript string safety" + ); + } + + #[test] + fn build_bid_map_emits_prebid_targeting_fields() { + let winning_bids = HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]); + + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + + assert_eq!( + bid_map.get("atf_sidebar"), + Some(&json!({ + "hb_pb": "1.23", + "hb_bidder": "rubicon", + "hb_adid": "ad-123", + "burl": "https://bidder.example/bill", + })), + "should emit Prebid targeting fields for priced bids" + ); + } + + #[test] + fn build_bid_map_uses_empty_ad_id_when_missing() { + let winning_bids = HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.0), None), + )]); + + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + + assert_eq!( + bid_map + .get("atf_sidebar") + .and_then(|bid| bid.get("hb_adid")), + Some(&json!("")), + "should use empty hb_adid when the provider did not return one" + ); + } + + #[test] + fn build_bid_map_omits_bids_without_price() { + let winning_bids = HashMap::from([ + ( + "priced".to_string(), + make_bid("priced", Some(2.0), Some("ad-priced")), + ), + ("aps".to_string(), make_bid("aps", None, Some("ad-aps"))), + ]); + + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + + assert!( + bid_map.contains_key("priced"), + "should keep bids with a price" + ); + assert!( + !bid_map.contains_key("aps"), + "should omit bids whose price must be decoded elsewhere" + ); + } + + #[test] + fn server_side_auction_allowed_requires_tcf_purpose_1_consent() { + let no_tcf = ConsentContext::default(); + let denied = ConsentContext { + tcf: Some(make_tcf_with_purpose_1(false)), + ..ConsentContext::default() + }; + let allowed = ConsentContext { + tcf: Some(make_tcf_with_purpose_1(true)), + ..ConsentContext::default() + }; + + assert!( + !server_side_auction_allowed(&no_tcf), + "absent TCF should deny server-side auction" + ); + assert!( + !server_side_auction_allowed(&denied), + "TCF without Purpose 1 consent should deny server-side auction" + ); + assert!( + server_side_auction_allowed(&allowed), + "TCF with Purpose 1 consent should allow server-side auction" + ); + } + + #[test] + fn cache_policy_sets_no_store_when_slots_matched_and_consent_denied() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + + apply_server_side_ad_cache_policy(&mut response, true); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "matched slots without injected globals should not be browser cached" + ); + } + + #[test] + fn cache_policy_sets_no_store_when_globals_are_injected() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + + apply_server_side_ad_cache_policy(&mut response, true); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "responses with request-scoped globals should not be browser cached" + ); + } + + #[test] + fn cache_policy_preserves_surrogate_headers() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + response.set_header("Surrogate-Control", "max-age=3600"); + response.set_header("Fastly-Surrogate-Control", "max-age=7200"); + + apply_server_side_ad_cache_policy(&mut response, true); + + assert_eq!( + response.get_header_str("Surrogate-Control"), + Some("max-age=3600"), + "should preserve origin Surrogate-Control" + ); + assert_eq!( + response.get_header_str("Fastly-Surrogate-Control"), + Some("max-age=7200"), + "should preserve origin Fastly-Surrogate-Control" + ); + } + + #[test] + fn cache_policy_preserves_origin_cache_headers_when_no_slots_match() { + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::CACHE_CONTROL, "public, max-age=300"); + response.set_header("Surrogate-Control", "max-age=3600"); + response.set_header("Fastly-Surrogate-Control", "max-age=7200"); + + apply_server_side_ad_cache_policy(&mut response, false); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("public, max-age=300"), + "no-match responses should preserve browser cache headers" + ); + assert_eq!( + response.get_header_str("Surrogate-Control"), + Some("max-age=3600"), + "no-match responses should preserve Surrogate-Control" + ); + assert_eq!( + response.get_header_str("Fastly-Surrogate-Control"), + Some("max-age=7200"), + "no-match responses should preserve Fastly-Surrogate-Control" + ); + } + #[test] fn supported_content_encoding_accepts_known_values() { assert!(is_supported_content_encoding(""), "should accept empty"); @@ -1366,6 +2301,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + ad_slots_script: None, }; let mut output = Vec::new(); @@ -1407,6 +2343,7 @@ 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, }; let mut output = Vec::new(); @@ -1419,6 +2356,401 @@ mod tests { ); } + #[test] + fn stream_publisher_body_injects_ad_slots_script_at_head_open() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: Some( + "<script>window.__ts_ad_slots=[];window.__ts_request_id=\"rid-1\";</script>" + .to_string(), + ), + }; + + let mut output = Vec::new(); + stream_publisher_body( + Body::from("<html><head><title>Test"), + &mut output, + ¶ms, + &settings, + ®istry, + ) + .expect("should process HTML body"); + let html = String::from_utf8(output).expect("should emit UTF-8 HTML"); + + assert!( + html.starts_with(""), + "should inject ad globals immediately after the head open tag: {html}" + ); + assert!( + !html.contains("window.__ts_bids"), + "should never inject bid data" + ); + } + + #[test] + fn stream_publisher_body_with_ad_auction_writes_completed_bid_map() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let services = noop_services(); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-stream", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: vec![AuctionResponse::success( + "prebid", + vec![make_bid("atf_sidebar", Some(1.239), Some("ad-123"))], + 8, + )], + mediator_response: None, + winning_bids: HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]), + total_time_ms: 8, + metadata: HashMap::new(), + }; + let pending_auction = + PendingAuction::from_completed_result_for_test(make_auction_request(), auction_result); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-stream".to_string(), + pending_auction, + PriceGranularity::Dense, + false, + settings.proxy.certificate_check, + ); + + let mut output = Vec::new(); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + stream_publisher_body_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &settings, + ®istry, + &mut auction_context, + ) + .expect("should stream body and poll auction"); + + match cache.try_get("rid-stream").expect("should read bid cache") { + CacheResult::Complete { bids } => { + assert_eq!( + bids.get("atf_sidebar"), + Some(&json!({ + "hb_pb": "1.23", + "hb_bidder": "rubicon", + "hb_adid": "ad-123", + "burl": "https://bidder.example/bill", + })), + "should write GPT bid targeting into cache" + ); + } + other => panic!("should complete bid cache, got {other:?}"), + } + } + + #[test] + fn stream_publisher_body_with_ad_auction_runs_inside_existing_executor() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let services = noop_services(); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-existing-executor", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + }; + let pending_auction = PendingAuction::from_completed_result_for_test( + make_auction_request(), + auction_result, + ); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-existing-executor".to_string(), + pending_auction, + PriceGranularity::Dense, + false, + settings.proxy.certificate_check, + ); + let mut output = Vec::new(); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + + stream_publisher_body_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &settings, + ®istry, + &mut auction_context, + ) + .expect("should not nest a local executor while streaming"); + }); + } + + #[test] + fn stream_publisher_body_with_ad_auction_fires_winning_nurl_when_enabled() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(204, Vec::new()); + let services = build_services_with_http_client(http_client.clone()); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-nurl", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: vec![AuctionResponse::success( + "prebid", + vec![make_bid("atf_sidebar", Some(1.239), Some("ad-123"))], + 8, + )], + mediator_response: None, + winning_bids: HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]), + total_time_ms: 8, + metadata: HashMap::new(), + }; + let pending_auction = + PendingAuction::from_completed_result_for_test(make_auction_request(), auction_result); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-nurl".to_string(), + pending_auction, + PriceGranularity::Dense, + true, + settings.proxy.certificate_check, + ); + + let mut output = Vec::new(); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + stream_publisher_body_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &settings, + ®istry, + &mut auction_context, + ) + .expect("should stream body and poll auction"); + + let expected_backend = BackendConfig::from_url( + "https://bidder.example/win", + settings.proxy.certificate_check, + ) + .expect("should build nurl backend"); + assert_eq!( + http_client.recorded_backend_names(), + vec![expected_backend], + "should fire the winning bid nurl through the platform HTTP client" + ); + } + + #[test] + fn process_response_streaming_with_ad_auction_writes_bids_for_buffered_html() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let services = noop_services(); + let params = ProcessResponseParams { + content_encoding: "", + origin_host: "origin.example.com", + origin_url: "https://origin.example.com", + request_host: "proxy.example.com", + request_scheme: "https", + settings: &settings, + content_type: "text/html; charset=utf-8", + integration_registry: ®istry, + ad_slots_script: None, + }; + let cache = InMemoryBidCache::new(std::time::Duration::from_secs(1), 4); + cache + .mark_pending( + "rid-buffered", + AuctionDeadline::from_timeout(std::time::Duration::from_millis(100)), + ) + .expect("should mark bid cache pending"); + let auction_result = OrchestrationResult { + provider_responses: vec![AuctionResponse::success( + "prebid", + vec![make_bid("atf_sidebar", Some(1.239), Some("ad-123"))], + 8, + )], + mediator_response: None, + winning_bids: HashMap::from([( + "atf_sidebar".to_string(), + make_bid("atf_sidebar", Some(1.239), Some("ad-123")), + )]), + total_time_ms: 8, + metadata: HashMap::new(), + }; + let pending_auction = + PendingAuction::from_completed_result_for_test(make_auction_request(), auction_result); + let mut server_side_auction = ServerSideAuctionStream::new( + "rid-buffered".to_string(), + pending_auction, + PriceGranularity::Dense, + false, + settings.proxy.certificate_check, + ); + let mut auction_context = ServerSideAuctionStreamContext { + services: &services, + bid_cache: &cache, + server_side_auction: &mut server_side_auction, + }; + + let mut output = Vec::new(); + process_response_streaming_with_ad_auction( + Body::from("ok"), + &mut output, + ¶ms, + &mut auction_context, + AuctionFinishMode::CompleteCollected, + ) + .expect("should process buffered HTML and poll auction"); + + match cache + .try_get("rid-buffered") + .expect("should read bid cache") + { + CacheResult::Complete { bids } => { + assert!( + bids.contains_key("atf_sidebar"), + "buffered processing should drain the auction into BidCache" + ); + } + other => panic!("should complete bid cache, got {other:?}"), + } + } + + #[test] + fn build_server_side_auction_request_uses_matched_slots_and_request_context() { + let settings = create_test_settings(); + let services = noop_services(); + let slot = make_creative_slot("atf_sidebar"); + let matched_slots = vec![&slot]; + let consent_context = ConsentContext::default(); + let mut req = Request::get("https://test-publisher.com/news/story?utm=1"); + req.set_header(header::USER_AGENT, "Mozilla/5.0 Test"); + + let auction_request = build_server_side_auction_request(ServerSideAuctionRequestParams { + request_id: "rid-article", + matched_slots: &matched_slots, + settings: &settings, + services: &services, + req: &req, + consent_context: &consent_context, + ec_id: "ec-id-1", + geo: None, + }) + .expect("should build auction request"); + + assert_eq!( + auction_request.id, "rid-article", + "should use the request ID as the auction ID" + ); + assert_eq!( + auction_request.slots.len(), + 1, + "should include matched creative slots only" + ); + assert_eq!( + auction_request.slots[0].id, "atf_sidebar", + "should convert creative slot to auction slot" + ); + assert_eq!( + auction_request.slots[0].floor_price, + Some(1.25), + "should preserve slot floor" + ); + assert_eq!( + auction_request.publisher.page_url.as_deref(), + Some("https://test-publisher.com/news/story?utm=1"), + "should use the actual page URL" + ); + assert_eq!( + auction_request.user.id, "ec-id-1", + "should use the request EC ID" + ); + assert!( + auction_request.user.consent.is_some(), + "should carry consent into provider requests" + ); + assert_eq!( + auction_request + .device + .as_ref() + .and_then(|device| device.user_agent.as_deref()), + Some("Mozilla/5.0 Test"), + "should forward user-agent in device context" + ); + } + /// Mid-stream decoder failure must surface as an error. The adapter /// relies on this: once headers are committed, it logs and drops the /// `StreamingBody` so the client sees a truncated response. If a decode @@ -1439,6 +2771,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, }; let bogus_body = Body::from(b"not gzip".to_vec()); @@ -1538,6 +2871,7 @@ 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, }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -1588,6 +2922,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, }; let mut output = Vec::new(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 785492620..b3fcb6238 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)] @@ -811,6 +814,27 @@ mod tests { settings.validate().expect("Failed to validate settings"); } + #[test] + fn settings_parses_creative_opportunities_section() { + let toml = format!( + r#"{} + +[creative_opportunities] +gam_network_id = "21765378893" +auction_timeout_ms = 500 +price_granularity = "dense" +"#, + crate_test_settings_str() + ); + + let settings = Settings::from_toml(&toml).expect("should parse"); + let creative_opportunities = settings + .creative_opportunities + .expect("should have creative opportunities"); + assert_eq!(creative_opportunities.gam_network_id, "21765378893"); + assert_eq!(creative_opportunities.auction_timeout_ms, Some(500)); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( diff --git a/creative-opportunities.toml b/creative-opportunities.toml new file mode 100644 index 000000000..981ff8d9c --- /dev/null +++ b/creative-opportunities.toml @@ -0,0 +1,17 @@ +# Slot templates for server-side ad auctions. +# Empty file = feature disabled. + +[[slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/21765378893/publisher/atf-sidebar" +div_id = "div-atf-sidebar" +page_patterns = ["/20**", "/about", "/news/*", "/"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.10 + +[slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" 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..76ad0566c --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md @@ -0,0 +1,1489 @@ +# 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 Trusted Server to match publisher page URLs to preconfigured ad slot templates, start the server-side auction at the edge, stream page HTML without auction delay, and serve bid targeting through `/ts-bids` so GPT can run without Prebid.js. + +**Architecture:** Phase 1 is implemented for Fastly Compute only. `creative-opportunities.toml` is compiled into the Fastly binary and contains URL-matched slot templates. A matched page request mints a UUID request ID, injects only `window.__ts_ad_slots` and `window.__ts_request_id` at `` open, and stores auction state/results in a Fastly Core Cache-backed `BidCache` rendezvous keyed by request ID. The auction must never delay the origin response or `` flushing; `/ts-bids?rid=` is the only bid delivery path. + +**Tech Stack:** Rust 2024, Fastly Compute, Fastly Core Cache, Fastly `PendingRequest::poll`, `lol_html` 2.7.2, `glob`, `serde`/`toml`, `uuid` v4, `std::time::Instant`, existing `AuctionOrchestrator` provider logic, TypeScript GPT shim. + +--- + +## Source of Truth and Invariants + +This plan implements `docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md`. Where older implementation notes conflict with that design, the design wins. + +Non-negotiable invariants: + +- The auction must not block origin response dispatch, HTML streaming, `` flushing, or FCP. +- No bid data is injected into HTML. Never add `window.__ts_bids`, `ad_bids_script`, or a `` hold. +- The only injected page globals are `window.__ts_ad_slots` and `window.__ts_request_id`. +- `/ts-bids` long-polls against the original `A_deadline = T0 + auction_timeout_ms`. +- Missing or empty `rid` returns `400`; unknown or expired request IDs return `404`; completed no-bid/timeout results return `{}`. +- If slots match but consent is absent or denied, do not fire the auction and do not inject ad globals; still set the browser-facing response to `Cache-Control: private, no-store`. +- Preserve `Surrogate-Control` and `Fastly-Surrogate-Control` unless the feasibility work proves Fastly requires a different cache strategy. +- Use the repo's actual Prebid config type: `PrebidIntegrationConfig`, not `PrebidConfig`. + +The April 15 spec has a few stale `__ts_bids` mentions in prose. Treat those as historical wording. Implementation uses `/ts-bids` and never sets `window.__ts_bids`. + +--- + +## Phase 1 Support Boundary + +Phase 1 targets Fastly only. Wire the publisher path, `/ts-bids`, auction completion, and `nurl` fire-and-forget in `crates/trusted-server-adapter-fastly` using the Fastly-supported primitive proven in Task 1. + +Keep core helpers platform-conscious where that falls out naturally, but do not add a cross-platform `AuctionIntent` abstraction or implement Cloudflare/Axum support in this plan. Until the EdgeZero migration reaches equivalent non-blocking HTTP polling, request rendezvous, and outbound HTTP semantics for other adapters, server-side ad templates may be disabled or return an explicit unsupported response on non-Fastly platforms. + +If Task 1 cannot prove the required Fastly behavior, stop implementation. Do not replace the April 15 behavior with a `/ts-bids`-initiated auction or any design where the browser bid request starts the auction. + +--- + +## File Map + +### New files + +| File | Responsibility | +| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `creative-opportunities.toml` | Slot templates, page patterns, formats, floors, targeting, APS params | +| `crates/trusted-server-core/src/creative_opportunities.rs` | Config types, TOML parsing, URL glob matching, slot-to-`AdSlot` conversion, validation | +| `crates/trusted-server-core/src/price_bucket.rs` | Prebid price granularity bucketing for `hb_pb` | +| `crates/trusted-server-core/src/bid_cache.rs` | Platform-neutral bid cache types, state machine, and in-memory test implementation | +| `crates/trusted-server-adapter-fastly/src/bid_cache.rs` | Fastly Core Cache-backed `BidCache` implementation for Phase 1 | +| `docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md` | Feasibility proof for the non-blocking auction path | + +### 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/src/lib.rs` | Export new modules | +| `crates/trusted-server-core/src/auction/types.rs` | Add `MediaType::banner()` and `Bid::ad_id` | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Populate `Bid::ad_id`; add `fire_nurl_at_edge` to `PrebidIntegrationConfig` | +| `crates/trusted-server-core/src/settings.rs` | Add `[creative_opportunities]` settings | +| `crates/trusted-server-core/build.rs` | Validate `creative-opportunities.toml` at build time | +| `crates/trusted-server-adapter-fastly/build.rs` | Rebuild adapter when `creative-opportunities.toml` changes | +| `crates/trusted-server-core/src/html_processor.rs` | Inject head globals at `` open only | +| `crates/trusted-server-core/src/platform/http.rs` | Add non-blocking pending request polling abstraction | +| `crates/trusted-server-core/src/auction/orchestrator.rs` | Add a pollable server-side auction path that can advance without blocking HTML streaming | +| `crates/trusted-server-core/src/publisher.rs` | Match slots, prepare head globals, set browser cache headers, dispatch non-blocking auction path | +| `crates/trusted-server-adapter-fastly/src/platform.rs` | Implement Fastly `PendingRequest::poll` in the platform HTTP client | +| `crates/trusted-server-adapter-fastly/src/main.rs` | Load slot file, initialize Fastly `BidCache`, add `/ts-bids`, pass publisher dependencies | +| `crates/trusted-server-core/src/integrations/gpt.rs` | Emit GPT bootstrap that installs `__tsAdInit` with `/ts-bids` fetch | +| `crates/js/lib/src/integrations/gpt/index.ts` | Add TypeScript `installTsAdInit` and burl firing | +| `trusted-server.toml` | Add `[creative_opportunities]` config | + +--- + +## Task 1: Feasibility Gate for Non-Blocking Auction Completion + +**Files:** + +- Create: `docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md` +- Inspect: `crates/trusted-server-core/src/platform/http.rs` +- Inspect: `crates/trusted-server-adapter-fastly/src/platform.rs` +- Inspect: Fastly crate docs/source for `PendingRequest::poll`, `send_async`, streaming response, and any supported background execution primitive + +This task is a stop/go gate. Do not implement the publisher-path auction trigger until this task selects and verifies a Fastly-supported mechanism that lets auction results continue to completion while the page response streams immediately. + +This proof is Fastly-only. It does not need to solve Cloudflare, Axum, or any future EdgeZero adapter. Non-Fastly support can remain unsupported/deferred in Phase 1. + +Acceptable proof outcomes: + +1. A verified background execution primitive exists and can complete `AuctionOrchestrator::run_auction` after response streaming starts. +2. A verified Fastly Core Cache rendezvous plus non-blocking `PendingRequest::poll` design exists that can advance auction pending requests between streaming chunks without delaying HTML chunks. +3. No primitive exists. In that case, stop implementation and update the report with the blocker. Do not replace this with a design that waits for auction completion before streaming. + +- [x] **Step 1: Inspect the runtime primitives** + + Read Fastly and local platform code for `send_async`, `PendingRequest::poll`, `PendingRequest::wait`, `select`, `stream_to_client`, Fastly Core Cache transactions/replacements, and any background execution API. + + Run: + + ```bash + rg -n "PendingRequest::poll|send_async|select|stream_to_client|background|spawn" \ + crates/trusted-server-core/src crates/trusted-server-adapter-fastly/src \ + ~/.cargo/registry/src + ``` + + Expected: enough evidence to identify whether background completion or non-blocking polling is feasible. + +- [x] **Step 2: Write the feasibility report** + + Create `docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md` with: + + ```markdown + # Server-Side Ad Templates Concurrency Feasibility + + ## Selected Primitive + + [Name the Fastly-supported primitive or state "blocked".] + + ## Evidence + + - [File/path and line references inspected] + - [Small spike, test, or manual Viceroy evidence] + + ## Required Publisher-Path Shape + + - Origin response must be dispatched immediately. + - HTML streaming must begin as soon as origin response headers are available. + - Auction completion must write to BidCache without waiting before streaming. + - /ts-bids must observe pending, complete, empty, and unknown states. + - BidCache must use Fastly Core Cache or another verified Fastly cross-request primitive; process-global memory is not sufficient because normal Fastly requests start separate Compute instances. + - Pending auction state must include the original auction deadline so /ts-bids can long-poll against A_deadline without minting a new timeout. + - Non-Fastly adapters are out of scope for Phase 1 and may remain unsupported. + + ## Stop/Go Decision + + [Go/Blocked] + ``` + +- [x] **Step 3: Verify the response-streaming and rendezvous invariant** + + Build a small spike or route-level test that proves a matched page can emit its first HTML bytes before a deliberately delayed auction result completes, and that `/ts-bids` can observe the same request ID moving from pending to complete through the selected Fastly rendezvous. + + Expected: evidence shows first page bytes are emitted before the delayed auction finishes, and `/ts-bids?rid=` sees the completed bid map without relying on process-global memory. + +- [x] **Step 4: Commit the report** + + ```bash + git add docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md + git commit -m "Document concurrency feasibility for server-side ad templates" + ``` + +--- + +## Task 2: Add `glob` Workspace Dependency + +**Files:** + +- Modify: `Cargo.toml` +- Modify: `crates/trusted-server-core/Cargo.toml` + +- [x] **Step 1: Write a temporary failing compile check** + + Temporarily add this to `crates/trusted-server-core/src/lib.rs`: + + ```rust + use glob::Pattern as _; + ``` + + Run: + + ```bash + cargo check --package trusted-server-core + ``` + + Expected: compile error because `glob` is not declared yet. + +- [x] **Step 2: Add the workspace dependency** + + In root `Cargo.toml` under `[workspace.dependencies]`, add: + + ```toml + glob = "0.3" + ``` + +- [x] **Step 3: Add the core crate dependency** + + In `crates/trusted-server-core/Cargo.toml` under `[dependencies]`, add: + + ```toml + glob = { workspace = true } + ``` + +- [x] **Step 4: Remove the temporary import and verify** + + Remove the temporary `use glob::Pattern as _;`. + + Run: + + ```bash + cargo check --package trusted-server-core + ``` + + Expected: clean compile. + +- [x] **Step 5: Commit** + + ```bash + git add Cargo.toml crates/trusted-server-core/Cargo.toml + git commit -m "Add glob dependency for creative opportunity matching" + ``` + +--- + +## Task 3: Price Bucket Module + +**Files:** + +- Create: `crates/trusted-server-core/src/price_bucket.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +Phase 1 implements Prebid built-in granularities: `low`, `medium`, `high`, `dense`, and `auto` (`auto` routes to `dense`). The April 15 design mentions `custom`, but no custom bucket schema is specified; do not implement `custom` in this task. + +- [x] **Step 1: Write failing tests** + + Create `crates/trusted-server-core/src/price_bucket.rs` with: + + ```rust + //! Prebid price granularity bucketing. + + #[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.015, PriceGranularity::Dense), "0.01"); + 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.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.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 built_in_granularities_cap_correctly() { + assert_eq!(price_bucket(5.01, PriceGranularity::Low), "5.00"); + assert_eq!(price_bucket(20.5, PriceGranularity::Medium), "20.00"); + assert_eq!(price_bucket(20.5, PriceGranularity::High), "20.00"); + assert_eq!(price_bucket(50.0, PriceGranularity::Dense), "20.00"); + } + + #[test] + fn auto_routes_to_dense() { + assert_eq!( + price_bucket(2.53, PriceGranularity::Auto), + price_bucket(2.53, PriceGranularity::Dense) + ); + } + } + ``` + + Run: + + ```bash + cargo test -p trusted-server-core price_bucket + ``` + + Expected: compile failure because the module implementation and export are missing. + +- [x] **Step 2: Implement `price_bucket.rs`** + + ```rust + //! Prebid price granularity bucketing. + + use serde::{Deserialize, Serialize}; + + /// Prebid price granularity setting. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] + #[serde(rename_all = "lowercase")] + pub enum PriceGranularity { + Low, + Medium, + #[default] + Dense, + High, + Auto, + } + + impl PriceGranularity { + /// Returns `Dense`; used as a serde default function pointer. + pub fn dense() -> Self { + Self::Dense + } + } + + /// Convert raw CPM to the `hb_pb` price bucket string. + #[must_use] + pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { + if cpm <= 0.0 { + return "0.00".to_string(); + } + + match granularity { + PriceGranularity::Low => bucket(cpm, 5.0, 0.50), + PriceGranularity::Medium => bucket(cpm, 20.0, 0.10), + PriceGranularity::High => bucket(cpm, 20.0, 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 bucket(cpm, 20.0, 0.50); + } + if cpm >= 3.0 { + return bucket(cpm, 8.0, 0.05); + } + bucket(cpm, 3.0, 0.01) + } + + fn bucket(cpm: f64, cap: f64, increment: f64) -> String { + let capped = cpm.min(cap); + format!("{:.2}", (capped / increment).floor() * increment) + } + ``` + +- [x] **Step 3: Export the module** + + In `crates/trusted-server-core/src/lib.rs`, add: + + ```rust + pub mod price_bucket; + ``` + +- [x] **Step 4: Run tests** + + ```bash + cargo test -p trusted-server-core price_bucket + ``` + + Expected: all price bucket tests pass. + +- [x] **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 bucket granularity" + ``` + +--- + +## Task 4: Auction Types for Slot Defaults and Bid Targeting + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` + +- [x] **Step 1: Write failing tests** + + In the `#[cfg(test)]` module in `crates/trusted-server-core/src/auction/types.rs`, add: + + ```rust + #[test] + fn media_type_banner_fn_returns_banner() { + assert_eq!(MediaType::banner(), MediaType::Banner); + } + + #[test] + fn bid_has_ad_id_field_for_gam_targeting() { + let bid = Bid { + slot_id: "atf".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: + + ```bash + cargo test -p trusted-server-core auction::types + ``` + + Expected: compile failure for missing `MediaType::banner` and `Bid::ad_id`. + +- [x] **Step 2: Add `MediaType::banner()`** + + Add: + + ```rust + impl MediaType { + /// Returns `Banner`; used as a serde default function pointer. + pub fn banner() -> Self { + Self::Banner + } + } + ``` + +- [x] **Step 3: Add `Bid::ad_id`** + + Add this field immediately before `metadata`: + + ```rust + /// Provider ad ID used for `hb_adid` targeting. + pub ad_id: Option, + ``` + + Update every `Bid` literal in tests and production code with `ad_id: None` unless parsing a real provider ad ID. + +- [x] **Step 4: Populate Prebid ad IDs** + + In `PrebidAuctionProvider::parse_bid`, add: + + ```rust + let ad_id = bid_obj + .get("adid") + .or_else(|| bid_obj.get("id")) + .and_then(|v| v.as_str()) + .map(String::from); + ``` + + Include `ad_id` in the returned `AuctionBid`. + +- [x] **Step 5: Run tests** + + ```bash + cargo test -p trusted-server-core auction::types integrations::prebid + ``` + + Expected: tests pass. + +- [x] **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 bid ad IDs for GAM targeting" + ``` + +--- + +## Task 5: Creative Opportunities Config and URL Matching + +**Files:** + +- Create: `crates/trusted-server-core/src/creative_opportunities.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +- [x] **Step 1: Write failing tests** + + Create `crates/trusted-server-core/src/creative_opportunities.rs` with tests for: + - `/20**` matches multi-segment article paths. + - `/` matches only the homepage. + - slot IDs allow only `[A-Za-z0-9_-]+`. + - `resolved_gam_unit_path()` defaults to `/{gam_network_id}/{id}`. + - `resolved_div_id()` defaults to slot ID. + - `to_ad_slot()` transfers formats, floors, targeting strings, and APS `slotID`. + - empty slot file parses and produces zero matches. + + Use this helper shape in tests: + + ```rust + 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(), + } + } + ``` + + Run: + + ```bash + cargo test -p trusted-server-core creative_opportunities + ``` + + Expected: compile failure because implementation/export is missing. + +- [x] **Step 2: Implement config types and matching** + + Implement: + + ```rust + use std::collections::HashMap; + + use glob::Pattern; + use serde::{Deserialize, Serialize}; + + use crate::auction::types::{AdFormat, AdSlot, MediaType}; + use crate::price_bucket::PriceGranularity; + + #[derive(Debug, Clone, Deserialize, Serialize)] + pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default = "PriceGranularity::dense")] + pub price_granularity: PriceGranularity, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct CreativeOpportunitySlot { + pub id: String, + pub gam_unit_path: Option, + pub div_id: Option, + pub page_patterns: Vec, + pub formats: Vec, + pub floor_price: Option, + #[serde(default)] + pub targeting: HashMap, + #[serde(default)] + pub providers: SlotProviders, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct CreativeOpportunityFormat { + pub width: u32, + pub height: u32, + #[serde(default = "MediaType::banner")] + pub media_type: MediaType, + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct SlotProviders { + pub aps: Option, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct ApsSlotParams { + pub slot_id: String, + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct CreativeOpportunitiesFile { + #[serde(rename = "slot", default)] + pub slots: Vec, + } + ``` + + Add methods for `matches_path`, `resolved_gam_unit_path`, `resolved_div_id`, `to_ad_slot`, `validate_slot_id`, and `match_slots`. + +- [x] **Step 3: Export the module** + + In `crates/trusted-server-core/src/lib.rs`, add: + + ```rust + pub mod creative_opportunities; + ``` + +- [x] **Step 4: Run tests** + + ```bash + cargo test -p trusted-server-core creative_opportunities + ``` + + Expected: tests pass. + +- [x] **Step 5: Commit** + + ```bash + git add crates/trusted-server-core/src/creative_opportunities.rs crates/trusted-server-core/src/lib.rs + git commit -m "Add creative opportunity config matching" + ``` + +--- + +## Task 6: Settings, Config File, and Build Validation + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `crates/trusted-server-core/build.rs` +- Create: `crates/trusted-server-adapter-fastly/build.rs` +- Create: `creative-opportunities.toml` +- Modify: `trusted-server.toml` + +- [x] **Step 1: Write failing settings test** + + In `settings.rs` tests, add: + + ```rust + #[test] + fn settings_parses_creative_opportunities_section() { + let toml = r#" + [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 + price_granularity = "dense" + "#; + + let settings = Settings::from_toml(toml).expect("should parse"); + let creative_opportunities = settings + .creative_opportunities + .expect("should have creative opportunities"); + assert_eq!(creative_opportunities.gam_network_id, "21765378893"); + assert_eq!(creative_opportunities.auction_timeout_ms, Some(500)); + } + ``` + + Run: + + ```bash + cargo test -p trusted-server-core settings_parses_creative_opportunities_section + ``` + + Expected: compile failure for missing `Settings::creative_opportunities`. + +- [x] **Step 2: Add settings field** + + Import `CreativeOpportunitiesConfig` and add: + + ```rust + #[serde(default)] + pub creative_opportunities: Option, + ``` + +- [x] **Step 3: Add root config files** + + Create `creative-opportunities.toml`: + + ```toml + # Slot templates for server-side ad auctions. + # Empty file = feature disabled. + + [[slot]] + id = "atf_sidebar_ad" + gam_unit_path = "/21765378893/publisher/atf-sidebar" + div_id = "div-atf-sidebar" + page_patterns = ["/20**"] + formats = [{ width = 300, height = 250 }] + floor_price = 0.50 + + [slot.targeting] + pos = "atf" + zone = "atfSidebar" + + [slot.providers.aps] + slot_id = "aps-slot-atf-sidebar" + ``` + + Add to `trusted-server.toml`: + + ```toml + [creative_opportunities] + gam_network_id = "21765378893" + auction_timeout_ms = 500 + price_granularity = "dense" + ``` + +- [x] **Step 4: Add build-time validation** + + In `crates/trusted-server-core/build.rs`, add `cargo:rerun-if-changed` for `../../creative-opportunities.toml`, parse it as `toml::Value`, and validate each `[[slot]].id` with `^[A-Za-z0-9_\-]+$`. + + Rules: + - Missing file: startup/build error. + - Malformed TOML: startup/build error. + - Empty file with zero slots: valid kill-switch. + - Invalid slot ID: startup/build error. + +- [x] **Step 5: Add adapter rebuild trigger** + + Create `crates/trusted-server-adapter-fastly/build.rs`: + + ```rust + fn main() { + println!("cargo:rerun-if-changed=../../../creative-opportunities.toml"); + } + ``` + +- [x] **Step 6: Run verification** + + ```bash + cargo test -p trusted-server-core settings_parses_creative_opportunities_section + cargo build --package trusted-server-core + cargo build --package trusted-server-adapter-fastly + ``` + + Expected: all pass/build. + +- [x] **Step 7: Commit** + + ```bash + git add crates/trusted-server-core/src/settings.rs crates/trusted-server-core/build.rs \ + crates/trusted-server-adapter-fastly/build.rs creative-opportunities.toml trusted-server.toml + git commit -m "Wire creative opportunities into settings and build validation" + ``` + +--- + +## Task 7: Bid Cache and `/ts-bids` Semantics + +**Files:** + +- Create: `crates/trusted-server-core/src/bid_cache.rs` +- Create: `crates/trusted-server-adapter-fastly/src/bid_cache.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +Keep the April 15 design's `BidCache` naming in Phase 1. This is the request-ID rendezvous used by the Fastly adapter; do not introduce a broader `AuctionIntent` abstraction in this plan. Production Fastly must not rely on process-global memory. Use Fastly Core Cache for pending/completed bid state, with an in-memory implementation only for unit tests and non-Fastly unsupported paths. + +- [x] **Step 1: Write failing tests** + + Create tests covering: + - unknown request ID -> `CacheResult::NotFound` + - pending request ID -> `CacheResult::Pending` + - pending entry carries the original auction deadline + - completed request ID -> bids returned + - expired entry -> not found + - `wait_for` returns bids immediately when complete + - `wait_for` returns `Empty` after original deadline passes + - `get_auction_deadline` returns the pending entry's original deadline + + Run: + + ```bash + cargo test -p trusted-server-core bid_cache + ``` + + Expected: compile failure because module is missing. + +- [x] **Step 2: Implement core bid cache types and test implementation** + + Implement: + + ```rust + pub type BidMap = std::collections::HashMap; + + #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] + pub enum BidCacheEntry { + Pending { auction_deadline_epoch_ms: u64 }, + Complete { bids: BidMap }, + } + + #[derive(Debug, Clone, Copy)] + pub struct AuctionDeadline { + pub instant: Instant, + pub epoch_ms: u64, + } + + #[derive(Debug)] + pub enum CacheResult { + Complete { bids: BidMap }, + Pending { auction_deadline: AuctionDeadline }, + NotFound, + } + + #[derive(Debug)] + pub enum WaitResult { + Bids(BidMap), + Empty, + NotFound, + } + + pub trait BidCache { + fn mark_pending(&self, request_id: &str, auction_deadline: AuctionDeadline) -> Result<(), BidCacheError>; + fn put(&self, request_id: &str, bids: BidMap) -> Result<(), BidCacheError>; + fn try_get(&self, request_id: &str) -> Result; + } + + pub struct InMemoryBidCache { + inner: std::sync::Mutex, + } + ``` + + Required methods: + - `new(ttl: Duration, capacity: usize) -> Self` + - `mark_pending(request_id: &str, auction_deadline: AuctionDeadline) -> Result<(), BidCacheError>` + - `put(request_id: &str, bids: BidMap) -> Result<(), BidCacheError>` + - `put_empty(request_id: &str)` or equivalent `put(request_id, HashMap::new())` + - `try_get(request_id: &str) -> Result` + - `get_auction_deadline(request_id: &str) -> Option` + - `wait_for(request_id: &str, deadline: AuctionDeadline) -> WaitResult` + + `AuctionDeadline` must be computed once when the page request starts from both `Instant::now()` and `SystemTime::now()`. In-process tests can use the `Instant`; Fastly Core Cache must persist `epoch_ms` and reconstruct an equivalent local `Instant` on `/ts-bids` so it can enforce the original deadline in a separate request. `wait_for` must not mint a new timeout. + +- [x] **Step 3: Implement Fastly Core Cache `BidCache`** + + In `crates/trusted-server-adapter-fastly/src/bid_cache.rs`, implement `FastlyBidCache`: + - Cache key: `ts-bids:`. + - `mark_pending`: insert `BidCacheEntry::Pending` with the original deadline and a short max-age, marking the cache object as sensitive data. + - `put`: replace/insert `BidCacheEntry::Complete` with the bid map. + - `try_get`: lookup and deserialize the cache object. + - Unknown/missing cache object maps to `CacheResult::NotFound`. + + Use `fastly::cache::core` APIs, not `static` process memory, for production Fastly rendezvous. + +- [x] **Step 4: Export the module** + + In `crates/trusted-server-core/src/lib.rs`, add: + + ```rust + pub mod bid_cache; + ``` + +- [x] **Step 5: Run tests** + + ```bash + cargo test -p trusted-server-core bid_cache + cargo test -p trusted-server-adapter-fastly bid_cache + ``` + + Expected: tests pass. + +- [x] **Step 6: Commit** + + ```bash + git add crates/trusted-server-core/src/bid_cache.rs crates/trusted-server-core/src/lib.rs \ + crates/trusted-server-adapter-fastly/src/bid_cache.rs crates/trusted-server-adapter-fastly/src/main.rs + git commit -m "Add request-scoped bid cache" + ``` + +--- + +## Task 8: HTML Head Globals Injection + +**Files:** + +- Modify: `crates/trusted-server-core/src/html_processor.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [x] **Step 1: Write failing HTML processor tests** + + Add tests proving: + - `ad_slots_script` is prepended at `` open. + - injected content contains `window.__ts_ad_slots` and `window.__ts_request_id`. + - output never contains `window.__ts_bids`. + - there is no `` end-tag handler or bid injection field. + + Run: + + ```bash + cargo test -p trusted-server-core html_processor + ``` + + Expected: compile failure for missing `HtmlProcessorConfig::ad_slots_script`. + +- [x] **Step 2: Add config field** + + Add to `HtmlProcessorConfig`: + + ```rust + /// Precomputed head globals script. Contains ad slots and request ID only. + pub ad_slots_script: Option, + ``` + + Initialize it to `None` in `HtmlProcessorConfig::from_settings`. + +- [x] **Step 3: Inject once at `` open** + + In the existing `element!("head", ...)` handler: + - Build one `snippet` string. + - Push `ad_slots_script` first when present. + - Then push integration head inserts. + - Then push tsjs script tags. + - Call `el.prepend(&snippet, ContentType::Html)` once. + + Do not add `on_end_tag`. + +- [x] **Step 4: Add publisher script helpers** + + In `publisher.rs`, add `pub(crate)` helpers: + - `build_head_globals_script(matched_slots, request_id, co_config) -> String` + - `html_escape_for_script(json: &str) -> String` + + The script must use `JSON.parse("...escaped JSON...")` and must not interpolate raw JSON into executable JavaScript. + +- [x] **Step 5: Run tests** + + ```bash + cargo test -p trusted-server-core html_processor + ``` + + Expected: tests pass. + +- [x] **Step 6: Commit** + + ```bash + git add crates/trusted-server-core/src/html_processor.rs crates/trusted-server-core/src/publisher.rs + git commit -m "Inject server-side ad globals at head open" + ``` + +--- + +## Task 9: Publisher Helpers for Bids, Consent, and Cache Headers + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [x] **Step 1: Write failing helper tests** + + Add tests covering: + - `build_head_globals_script` includes slots and request ID. + - script escaping neutralizes ``, `<`, `>`, `&`, U+2028, and U+2029. + - `build_bid_map` emits `hb_pb`, `hb_bidder`, `hb_adid`, and `burl`. + - bids with `price: None` are omitted. + - `server_side_auction_allowed` returns true only when TCF exists and purpose 1 is consented. + - response cache policy sets `Cache-Control: private, no-store` when slots matched and consent is denied. + - response cache policy sets `Cache-Control: private, no-store` when globals are injected. + - response cache policy does not remove `Surrogate-Control` or `Fastly-Surrogate-Control`. + - no-match responses preserve origin cache headers. + + Run: + + ```bash + cargo test -p trusted-server-core publisher::creative_opportunities_tests + ``` + + Expected: failure because helpers are missing. + +- [x] **Step 2: Implement bid map helper** + + Implement `build_bid_map`: + + ```rust + pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap, + price_granularity: crate::price_bucket::PriceGranularity, + ) -> crate::bid_cache::BidMap { + winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + let cpm = bid.price?; + Some(( + slot_id.clone(), + serde_json::json!({ + "hb_pb": crate::price_bucket::price_bucket(cpm, price_granularity), + "hb_bidder": bid.bidder, + "hb_adid": bid.ad_id.as_deref().unwrap_or(""), + "burl": bid.burl, + }), + )) + }) + .collect() + } + ``` + +- [x] **Step 3: Implement consent helper** + + Implement a small helper used only by the server-side ad-template path: + + ```rust + fn server_side_auction_allowed(consent_context: &crate::consent::ConsentContext) -> bool { + consent_context + .tcf + .as_ref() + .is_some_and(|tcf| tcf.has_purpose_consent(1)) + } + ``` + + This intentionally follows the April 15 design: absent TCF means no server-side auction and no ad globals for this Phase 1 path. + +- [x] **Step 4: Implement browser cache policy helper** + + Implement a helper that receives `slots_matched: bool` and `globals_injected: bool`. + + Required behavior: + - If no slots matched, do nothing. + - If slots matched and consent denied, set `Cache-Control: private, no-store`. + - If globals are injected, set `Cache-Control: private, no-store`. + - Preserve `Surrogate-Control` and `Fastly-Surrogate-Control`. + +- [x] **Step 5: Run tests** + + ```bash + cargo test -p trusted-server-core publisher::creative_opportunities_tests + ``` + + Expected: tests pass. + +- [x] **Step 6: Commit** + + ```bash + git add crates/trusted-server-core/src/publisher.rs + git commit -m "Add publisher helpers for ad template responses" + ``` + +--- + +## Task 10: GPT `__tsAdInit` Bootstrap + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/js/lib/src/integrations/gpt/index.test.ts` + +- [x] **Step 1: Write failing Rust test** + + In `gpt.rs`, add a test that combines `head_inserts()` output and asserts: + - contains `__tsAdInit` + - contains `/ts-bids` + - contains `__ts_request_id` + - contains `bidsPromise` + - contains `slotRenderEnded` + - contains `sendBeacon` + - does not contain `__ts_bids` + + Run: + + ```bash + cargo test -p trusted-server-core integrations::gpt + ``` + + Expected: failure because `__tsAdInit` is missing. + +- [x] **Step 2: Extend GPT Rust head injector** + + Keep the existing GPT shim install snippet and add an inline `__tsAdInit` snippet that: + - reads `window.__ts_ad_slots || []` + - reads `window.__ts_request_id` + - starts `fetch('/ts-bids?rid=' + encodeURIComponent(rid), { credentials: 'omit' })` + - catches failures and resolves to `{}` + - defines GPT slots immediately + - applies static targeting immediately + - waits for `bidsPromise` before applying `hb_*` targeting and calling `refresh()` + - fires `burl` through `navigator.sendBeacon` only after `slotRenderEnded` confirms matching `hb_adid` + +- [x] **Step 3: Write failing TypeScript tests** + + Add tests for: + - `/ts-bids?rid=` fetch with `credentials: 'omit'` + - static slot targeting applied before refresh + - `hb_pb`, `hb_bidder`, and `hb_adid` applied before refresh + - fetch failure still calls `refresh()` + - `slotRenderEnded` fires `burl` only when rendered slot targeting `hb_adid` matches the bid + + Run: + + ```bash + cd crates/js/lib && npx vitest run src/integrations/gpt/index.test.ts + ``` + + Expected: failure because `installTsAdInit` is missing. + +- [x] **Step 4: Implement `installTsAdInit`** + + In `index.ts`, export `installTsAdInit()` and call it from the integration initialization path. Keep the existing GPT guard behavior. + +- [x] **Step 5: Run tests and build** + + ```bash + cargo test -p trusted-server-core integrations::gpt + cd crates/js/lib && npx vitest run src/integrations/gpt/index.test.ts + cd crates/js/lib && node build-all.mjs + ``` + + Expected: all pass. + +- [x] **Step 6: Commit** + + ```bash + git add crates/trusted-server-core/src/integrations/gpt.rs crates/js/lib/src/integrations/gpt + git commit -m "Add GPT bid fetch bootstrap" + ``` + +--- + +## Task 11: `/ts-bids` Endpoint + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +This route is Fastly-only for Phase 1. Future adapters can omit this route, return unsupported, or keep server-side ad templates disabled until the EdgeZero migration provides equivalent request-rendezvous semantics. + +- [x] **Step 1: Write failing tests** + + Add route or helper tests for: + - missing `rid` -> `400` + - unknown `rid` -> `404` + - completed bids -> `200` JSON body with slot bid map + - completed empty bid map -> `200` body `{}` + - pending request reaches original deadline -> `200` body `{}` + - all responses set `Cache-Control: private, no-store` + + Run: + + ```bash + cargo test -p trusted-server-adapter-fastly ts_bids + ``` + + Expected: failure because handler is missing. + +- [x] **Step 2: Add route** + + Before the publisher fallback route, add: + + ```rust + (Method::GET, "/ts-bids") => Ok(handle_ts_bids_request(req, bid_cache)), + ``` + + Adjust `route_request` parameters to receive `bid_cache: &BidCache`. + +- [x] **Step 3: Implement handler** + + Handler behavior: + - Parse `rid` from query. + - Missing/empty `rid`: `400` plain text. + - Call `bid_cache.try_get(&rid)`. + - `CacheResult::Pending { auction_deadline }`: long-poll by rechecking `try_get` until the persisted original deadline or completion. + - `WaitResult::Bids(bids)`: serialize bids as JSON. + - Empty map serializes as `{}`. + - `WaitResult::Empty`: return `200` with `{}`. + - `WaitResult::NotFound`: return `404`. + - Always set `Cache-Control: private, no-store`. + +- [x] **Step 4: Run tests** + + ```bash + cargo test -p trusted-server-adapter-fastly ts_bids + ``` + + Expected: tests pass. + +- [x] **Step 5: Commit** + + ```bash + git add crates/trusted-server-adapter-fastly/src/main.rs + git commit -m "Add ts-bids endpoint" + ``` + +--- + +## Task 12: Publisher Path Integration + +**Files:** + +- Modify: `crates/trusted-server-core/src/platform/http.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +Do not start this task unless Task 1 has a `Go` decision. Implement exactly the non-blocking shape selected by Task 1. + +- [x] **Step 1: Write failing integration tests** + + Add tests proving: + - platform HTTP client exposes a non-blocking `poll` operation for pending requests + - Fastly `poll` maps `PendingRequest::poll()` pending/done/error states into platform-neutral results + - server-side auction can be started and advanced through a non-blocking poll method + - no matching slots -> no globals, no auction pending entry, cache headers preserved + - matching slots + denied/missing consent -> no globals, no auction pending entry, `Cache-Control: private, no-store` + - matching slots + consent allowed -> globals injected, pending entry registered with original deadline + - origin response is returned/streamed without waiting for auction completion + - completed auction writes `BidCache` + - auction failure writes empty bid map or otherwise lets `/ts-bids` resolve `{}` before deadline + - `Surrogate-Control` and `Fastly-Surrogate-Control` are preserved + + Run: + + ```bash + cargo test -p trusted-server-core publisher + ``` + + Expected: failures for missing integration. + +- [x] **Step 2: Add platform pending-request polling** + + Extend `PlatformHttpClient` with a non-blocking poll method: + + ```rust + async fn poll( + &self, + pending: PlatformPendingRequest, + ) -> Result>; + ``` + + `PlatformPollResult` must include: + - `Pending(PlatformPendingRequest)` + - `Ready(Result>)` + + In the Fastly adapter, implement it with `fastly::http::request::PollResult` from `PendingRequest::poll()`. Non-Fastly/test implementations may return `PlatformError::Unsupported` until EdgeZero migration adds equivalent primitives. + +- [x] **Step 3: Add pollable auction progression** + + Refactor auction orchestration without changing existing `/auction` behavior: + - Keep `AuctionOrchestrator::run_auction` for existing endpoints. + - Add a server-side-template path that can `start` provider requests and return a `PendingAuction`. + - `PendingAuction::poll_once()` must call platform `poll` and return immediately. + - `PendingAuction::finish_due_to_deadline()` must drop remaining pending requests and compute winners from responses collected so far. + - Parsing and winning-bid selection must reuse existing provider/orchestrator logic. + + This is the mechanism that lets publisher streaming continue while auction work advances opportunistically between streaming chunks. + +- [x] **Step 4: Load creative opportunities and bid cache in adapter** + + In `main.rs`, add: + + ```rust + const CREATIVE_OPPORTUNITIES_TOML: &str = + include_str!("../../../creative-opportunities.toml"); + ``` + + Parse immutable creative opportunity config through a process-global lazy value if compatible with Fastly Compute: + + ```rust + static CREATIVE_OPPORTUNITIES: std::sync::LazyLock< + trusted_server_core::creative_opportunities::CreativeOpportunitiesFile, + > = std::sync::LazyLock::new(|| { + toml::from_str(CREATIVE_OPPORTUNITIES_TOML) + .expect("should parse creative-opportunities.toml") + }); + ``` + + Initialize Fastly `BidCache` through the Task 1 verified Core Cache-backed implementation. Do not use process-global request state for production bid rendezvous. + + The Fastly bid cache itself should be a lightweight value over Core Cache APIs and may be constructed per request because the state lives in Fastly Core Cache, not the Rust object. + +- [x] **Step 5: Update publisher handler signature** + + Add the dependencies required by the selected Task 1 shape: + - `orchestrator: &AuctionOrchestrator` + - `slots_file: &CreativeOpportunitiesFile` + - `bid_cache: &BidCache` + + Keep `AuctionContext` construction aligned with current code: include `settings`, `request`, `client_info`, `timeout_ms`, `provider_responses`, and `services`. + +- [x] **Step 6: Match slots and decide consent before origin body processing** + + Required behavior: + - Mint `request_id` only when slots match. + - Match against `req.get_path()`. + - If no slots match, do not register `BidCache`, do not inject globals, and preserve cache headers. + - If slots match but consent is denied/absent, do not run auction and do not inject globals; set browser `Cache-Control: private, no-store`. + - If slots match and consent allows, register pending cache entry with `A_deadline`, inject globals, and dispatch auction through the Task 1 verified non-blocking path. + +- [x] **Step 7: Preserve streaming invariant** + + The implementation must satisfy: + - Origin request is dispatched immediately. + - Page response headers/body are not held for `run_auction`. + - No `wait()` or blocking `select()` for auction work occurs before the page starts streaming. + - During body streaming, auction work may only use non-blocking `poll` calls between chunk writes. + - If the auction completes after page streaming starts, it writes bid results to `BidCache`. + +- [x] **Step 8: Force chunked browser response for processed HTML** + + For responses that enter the HTML processing path: + - Remove `Content-Length`. + - Set `Transfer-Encoding: chunked` if Fastly permits it explicitly. + - Do not force chunked on binary pass-through responses. + +- [x] **Step 9: Run tests** + + ```bash + cargo test -p trusted-server-core publisher + cargo test -p trusted-server-adapter-fastly + ``` + + Expected: tests pass. + +- [x] **Step 10: Commit** + + ```bash + git add crates/trusted-server-core/src/platform/http.rs crates/trusted-server-adapter-fastly/src/platform.rs \ + crates/trusted-server-core/src/auction/orchestrator.rs crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-adapter-fastly/src/main.rs + git commit -m "Start ad template auctions without blocking HTML streaming" + ``` + +--- + +## Task 13: Server-Side `nurl` Fire-and-Forget + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` + +This task implements `nurl` firing only for the Fastly Phase 1 path. Future adapters may return unsupported or disable server-side ad templates until they can provide equivalent non-blocking outbound HTTP behavior. + +- [x] **Step 1: Write failing config test** + + In Prebid tests, assert: + + ```rust + #[test] + fn prebid_fire_nurl_at_edge_defaults_to_true() { + let config = PrebidIntegrationConfig { + fire_nurl_at_edge: true, + ..base_config() + }; + assert!(config.fire_nurl_at_edge, "should default to edge nurl firing"); + } + ``` + + Adjust to the existing test helper style in `prebid.rs`. + + Run: + + ```bash + cargo test -p trusted-server-core integrations::prebid + ``` + + Expected: failure until field/default is implemented. + +- [x] **Step 2: Add config field** + + Add to `PrebidIntegrationConfig`: + + ```rust + #[serde(default = "default_fire_nurl_at_edge")] + pub fire_nurl_at_edge: bool, + ``` + + Add: + + ```rust + fn default_fire_nurl_at_edge() -> bool { + true + } + ``` + +- [x] **Step 3: Fire winning nurls after auction completion** + + In the selected non-blocking auction completion path, after writing bid results to `BidCache`, call a helper that: + - Reads `PrebidIntegrationConfig` via `settings.integrations.get_typed::("prebid")`. + - Defaults to `true` if config is absent. + - Uses the Fastly-supported async HTTP primitive from Task 1, for example `fastly::Request::get(nurl).send_async(&backend_name)`, for each winning bid with `nurl`. + - Logs warnings but never fails the page or `/ts-bids`. + +- [x] **Step 4: Run tests** + + ```bash + cargo test -p trusted-server-core integrations::prebid publisher + ``` + + Expected: tests pass. + +- [x] **Step 5: Commit** + + ```bash + git add crates/trusted-server-core/src/integrations/prebid.rs crates/trusted-server-core/src/publisher.rs + git commit -m "Fire Prebid nurls from the edge" + ``` + +--- + +## Task 14: End-to-End Verification + +**Files:** + +- Modify only if needed by test fixes. + +- [x] **Step 1: Run Rust tests** + + ```bash + cargo test --workspace + ``` + + Expected: all tests pass. + +- [x] **Step 2: Run Rust formatting check** + + ```bash + cargo fmt --all -- --check + ``` + + Expected: no formatting changes needed. + +- [x] **Step 3: Run Clippy** + + ```bash + cargo clippy --workspace --all-targets --all-features -- -D warnings + ``` + + Expected: no warnings. + +- [x] **Step 4: Run JS tests and build** + + ```bash + cd crates/js/lib && npx vitest run + cd crates/js/lib && node build-all.mjs + ``` + + Expected: tests and build pass. + +- [x] **Step 5: Run manual Fastly verification** + + Run: + + ```bash + fastly compute serve + ``` + + In another shell, use the local server from `fastly.toml`: + + ```bash + curl -i http://127.0.0.1:7676/about + curl -i http://127.0.0.1:7676/2024/01/test-article/ + curl -i "http://127.0.0.1:7676/ts-bids" + curl -i "http://127.0.0.1:7676/ts-bids?rid=not-real" + ``` + + For a matched page with valid consent, copy the `window.__ts_request_id` value from the HTML and run: + + ```bash + curl -i "http://127.0.0.1:7676/ts-bids?rid=" + ``` + + Verify with curl output: + - `/about`: no `__ts_ad_slots`, no `__ts_request_id`, no TS-added `Cache-Control: private, no-store`. + - matched URL with valid consent: contains `__ts_ad_slots` and `__ts_request_id` at `` open, no `__ts_bids`, browser-facing `Cache-Control: private, no-store`. + - matched URL with denied/missing consent: no ad globals and browser-facing `Cache-Control: private, no-store`. + - `Surrogate-Control` and `Fastly-Surrogate-Control` from origin are preserved. + - `/ts-bids?rid=` returns JSON, `Content-Type: application/json`, `Cache-Control: private, no-store`. + - `/ts-bids` without `rid` returns `400`. + - `/ts-bids?rid=not-real` returns `404`. + - First HTML bytes arrive before a delayed auction completes, using the evidence path from Task 1. + +- [x] **Step 6: Run browser verification** + + With `fastly compute serve` still running, verify manually in Chrome or with Chrome MCP: + - Open `http://127.0.0.1:7676/2024/01/test-article/`. + - Evaluate `window.__ts_ad_slots` and `window.__ts_request_id`; both should exist only on matched, consent-allowed pages. + - Evaluate `window.__ts_bids`; it should be `undefined`. + - Inspect the Network panel and confirm the page issues a single `/ts-bids?rid=` request. + - Confirm the console has no GPT bootstrap errors. + - Confirm no ad globals exist on `/about` or matched pages with denied/missing consent. + +- [x] **Step 7: Commit any final test/documentation fixes** + + ```bash + git status --short + git add + git commit -m "Verify server-side ad templates end to end" + ``` + +--- + +## Known Limitations and Follow-Ups + +| Item | Notes | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly-only Phase 1 | Non-Fastly adapters may remain unsupported until EdgeZero migration provides equivalent non-blocking HTTP polling, rendezvous, and HTTP semantics. | +| Concurrency primitive | Must be proven in Task 1. If Fastly cannot complete auction work after streaming starts, implementation stops rather than violating FCP. | +| `BidCache` locality | Fastly Core Cache is POP/locality scoped. `/ts-bids` can miss if routed away from the page request's cache locality; it should fail closed to `{}` or `404` as specified. | +| `custom` price granularity | Mentioned in the design but no schema is defined. Phase 1 implements built-in Prebid granularities only. | +| Dynamic slot config | Phase 1 uses `include_str!`; slot changes require redeploy. KV-backed config is a follow-up. | +| Server-side GAM | Out of scope. GPT remains client-side in Phase 1. | +| PBS stored requests | Slot IDs must exist in PBS stored request configuration before production rollout. | +| Dynamic backend allowlist | `nurl` fire-and-forget requires SSP domains to be allowed by Fastly dynamic backend policy. | diff --git a/docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md b/docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md new file mode 100644 index 000000000..c25e0d9cd --- /dev/null +++ b/docs/superpowers/reports/2026-04-30-server-side-ad-templates-concurrency.md @@ -0,0 +1,42 @@ +# Server-Side Ad Templates Concurrency Feasibility + +## Selected Primitive + +Go with a Fastly-only shape: + +- Fastly `Request::send_async()` starts origin and bidder/provider requests without waiting for their responses. +- Fastly `PendingRequest::poll()` advances bidder/provider requests without blocking page streaming. +- Fastly `Response::stream_to_client()` commits browser response headers as soon as origin headers are ready. +- Fastly Core Cache stores the request-ID rendezvous state for `/ts-bids`; process-global memory is not used for production bid delivery. + +This means the implementation should not call the existing blocking `AuctionOrchestrator::run_auction()` on the publisher streaming path. It needs a pollable auction path that starts provider requests, polls them between streaming writes, and writes completed or empty bid results to the Core Cache-backed `BidCache`. + +## Evidence + +- `Cargo.lock` uses `fastly = 0.11.12`. +- Fastly SDK confirms normal request isolation: `fastly-0.11.12/src/experimental/reusable_sessions.rs:3` says each incoming HTTP request normally starts a new Compute instance. Therefore a process-global `static`/`LazyLock` `BidCache` is not a valid production rendezvous for a page request and a later `/ts-bids` request. +- Fastly SDK exposes non-blocking pending request polling: `fastly-0.11.12/src/http/request/pending.rs:39` through `:43` documents `PendingRequest::poll()` as immediately returning a `PollResult`. +- Fastly SDK preserves pending handles when not ready: `fastly-0.11.12/src/http/request/pending.rs:50` through `:52` returns a new pending request handle when a backend response is not ready. +- Fastly SDK exposes blocking `select()`, but it is not acceptable on the publisher streaming critical path: `fastly-0.11.12/src/http/request/pending.rs:94` through `:96` documents that `select` blocks until one request is ready. +- Fastly SDK `Request::send_async()` starts backend work without waiting: `fastly-0.11.12/src/http/request.rs:709` through `:719`. +- Fastly SDK `Response::stream_to_client()` commits response headers immediately: `fastly-0.11.12/src/http/response.rs:1734` through `:1745`. +- Fastly SDK `StreamingBody` writes are buffered and can be flushed to emit chunks: `fastly-0.11.12/src/http/body/streaming.rs:20` through `:22`, and `:123` through `:125`. +- Fastly Core Cache supports one-off `lookup()`/`insert()` for arbitrary cached objects: `fastly-0.11.12/src/cache/core.rs:30` through `:35`, `:143`, and `:611`. +- Fastly Core Cache replacement keeps an existing object accessible while the replacement is being written by default: `fastly-0.11.12/src/cache/core/replace.rs:15` through `:28`. + +## Required Publisher-Path Shape + +- Compute `A_deadline = T0 + auction_timeout_ms` once at the start of a matched, consent-allowed page request. +- Insert a pending `BidCacheEntry` into Fastly Core Cache before emitting `window.__ts_request_id`; the entry must include the original deadline as epoch milliseconds. +- Dispatch origin and provider requests immediately with `send_async()`. +- Start streaming HTML as soon as origin response headers are available. +- During streaming, only advance auction state through non-blocking `PendingRequest::poll()` calls between chunk writes. +- When all provider requests finish or the original deadline expires, replace the Core Cache entry with a completed bid map. Auction errors must replace it with an empty bid map. +- `/ts-bids` must read the Core Cache entry. Pending entries long-poll only until the persisted original deadline; completed empty maps return `{}`; unknown/expired IDs return `404`. +- Non-Fastly adapters remain unsupported/deferred for this feature until EdgeZero migration provides equivalent primitives. + +## Stop/Go Decision + +Go, with the plan corrected to use Fastly Core Cache for request-ID rendezvous and `PendingRequest::poll()` for non-blocking auction progress. + +Do not implement a process-global production `BidCache`. Do not implement `/ts-bids`-initiated auctions. Do not call blocking `select()` or `run_auction()` before the page has started streaming. diff --git a/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md new file mode 100644 index 000000000..072897d5f --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md @@ -0,0 +1,955 @@ +# Server-Side Ad Templates Design + +_Author · 2026-04-15_ + +--- + +## 1. Problem Statement + +Today's display ad pipeline on most publisher sites is structurally sequential and +browser-bound: + +1. Page HTML arrives at browser +2. Prebid.js (~300KB) downloads and parses +3. Smart Slots SDK scans the DOM to discover ad placements +4. `addAdUnits()` registers slot definitions +5. Prebid auction fires from the browser (~80–150ms RTT to SSPs) +6. Bids return (~1,000–1,500ms window) +7. GPT `setTargeting()` + `refresh()` fires +8. GAM creative renders + +**Total time to ad visible: ~3,100ms.** + +The browser is the slowest possible place to run an auction. It must first download and +parse multiple SDKs, scan the DOM to discover what ad slots exist, and then fire SSP +requests over a consumer internet connection with high and variable latency. + +Trusted Server sits at the Fastly edge — milliseconds from the user, with +data-center-to-data-center RTT to Prebid Server (~20–30ms vs ~80–150ms from a browser). +The server knows, from the request URL alone, exactly which ad slots are available on any +given page. There is no reason to wait for the browser. + +--- + +## 2. Goal + +Enable Trusted Server to: + +1. Match an incoming page request URL against a set of pre-configured slot templates +2. Immediately fire the full server-side auction (all providers: PBS, APS, future wrappers) + in parallel with the origin HTML fetch — before the browser receives a single byte +3. Inject GPT slot definitions into `` so the client can define slots without any SDK +4. Cache auction results at the edge keyed by request ID. Serve them to the client via + a fast `/ts-bids` endpoint when the client fetches them. Bid delivery is decoupled + from page rendering — the auction never blocks FCP. The `/auction` POST endpoint is + retained as a fallback for pages whose URLs do not match any slot template, + preserving backward compatibility for publishers who have not yet adopted + `creative-opportunities.toml`. +5. Eliminate Prebid.js from the client entirely + +**Target time to ad visible: ~1,150ms. FCP unchanged from a no-TS baseline. Net saving +on ad-visible: ~2,100ms.** + +> **Note:** The latency numbers in this document are modeled estimates based on known +> edge→PBS RTT ranges and typical origin response times. They should be validated with +> production measurements after Phase 1 ships. + +--- + +## 3. Non-Goals + +- Eliminating client-side GPT / Google Ad Manager — GAM remains in the rendering + pipeline for Phase 1. The GAM call (`securepubads.g.doubleclick.net`) moves + server-side in a future phase (see §9.6). +- Dynamic slot discovery (reading the DOM) — this design commits to pre-defined, + URL-matched slot templates. Smart Slots' dynamic injection behavior is replaced by + server knowledge. +- Changing the `AuctionOrchestrator` internally — the orchestrator already handles + parallel provider fan-out. This design adds a new trigger point, not new auction + logic. + +--- + +## 4. Architecture + +### 4.1 New File: `creative-opportunities.toml` + +A new config file at the repo root, alongside `trusted-server.toml`. It holds all slot +templates: page pattern matching rules, ad formats, floor prices, GAM targeting +key-values, and per-provider bidder params. PBS bidder-level params (placement IDs, +account IDs) live in Prebid Server stored requests, keyed by slot ID. APS params are +specified inline per slot under `[slot.providers.aps]`. + +Loaded at build time via `include_str!()` and compiled into the WASM binary. Slot +changes require a redeploy; this is intentional (fast reads, no KV overhead, no +per-request cost). A migration path to KV-backed config is tracked in §9.5. + +`floor_price` is the publisher-owned hard floor per slot — the source of truth for the +minimum acceptable bid price, enforced at the edge before bids reach the ad server. Any +bid below the floor is discarded at the orchestrator level before it enters `__ts_bids`. +SSPs may apply their own dynamic floors independently within their platforms; this floor +is the publisher's baseline that supersedes all other floor logic by virtue of being +enforced earliest in the pipeline. + +#### Top-level config (in `trusted-server.toml`) + +```toml +[creative_opportunities] +# GAM network ID used to construct default ad-unit paths. +gam_network_id = "21765378893" + +# Optional. Defaults to [auction].timeout_ms if not set. +# Recommended: 500ms (vs client-side 1000–1500ms) due to lower edge→PBS RTT. +# This value gates both the auction deadline and the -boundary hold in +# streaming mode — they share the same deadline (T₀ + auction_timeout_ms). +auction_timeout_ms = 500 + +# Granularity table for hb_pb price bucket strings. +# Options: "low" | "medium" | "high" | "auto" | "dense" | "custom" +# Defaults to "dense" if not set. +price_granularity = "dense" +``` + +#### `creative-opportunities.toml` schema + +```toml +[[slot]] +id = "atf_sidebar_ad" +# Optional. Defaults to "/{gam_network_id}/{id}". +# Override for non-standard GAM ad-unit paths. +gam_unit_path = "/21765378893/publisher/atf-sidebar" +# Optional. DOM container element ID. Defaults to slot id. +div_id = "div-atf-sidebar" +page_patterns = ["/20**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" + +[[slot]] +id = "below-content-ad" +page_patterns = ["/20**"] +formats = [{ width = 300, height = 250 }, { width = 728, height = 90 }] +floor_price = 0.25 + +[slot.targeting] +pos = "btf" +zone = "belowContent" + +[slot.providers.aps] +slot_id = "aps-slot-below-content" + +[[slot]] +id = "ad-homepage-0" +page_patterns = ["/", "/index.html"] +formats = [{ width = 970, height = 250 }, { width = 728, height = 90 }] +floor_price = 1.00 + +[slot.targeting] +pos = "atf" +zone = "homepage" +slot_index = "0" + +[slot.providers.aps] +slot_id = "aps-slot-homepage-0" +``` + +#### Rust types + +```rust +#[derive(Debug, Clone, serde::Deserialize)] +pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default = "default_auction_timeout_ms")] + pub auction_timeout_ms: Option, + #[serde(default = "PriceGranularity::dense")] + pub price_granularity: PriceGranularity, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct CreativeOpportunitySlot { + pub id: String, + pub gam_unit_path: Option, // defaults to /{gam_network_id}/{id} + pub div_id: Option, // defaults to id + pub page_patterns: Vec, + pub formats: Vec, + pub floor_price: Option, + #[serde(default)] + pub targeting: HashMap, // strings only — validated at startup + #[serde(default)] + pub providers: SlotProviders, +} + +/// Separate from auction::AdFormat so media_type can default to Banner +/// without requiring it in the TOML. Converted to AdFormat at auction time. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct CreativeOpportunityFormat { + pub width: u32, + pub height: u32, + #[serde(default = "MediaType::banner")] + pub media_type: MediaType, +} + +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct SlotProviders { + pub aps: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ApsSlotParams { + pub slot_id: String, +} +``` + +> **Targeting value types:** `targeting` values are `String`-only (not +> `serde_json::Value`). GPT's `setTargeting()` only accepts `string | string[]`; +> non-string values are silently dropped by the browser. Validated at startup — a +> non-string targeting value is a startup error. + +> **Slot ID validation:** Slot IDs are validated at startup against a strict allowlist +> (`[A-Za-z0-9_-]+`). IDs outside this set fail startup. This prevents XSS via +> crafted IDs appearing in the injected ` +``` + +> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped +> before insertion into the `