diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 9f726bb9..31f66d0a 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -20,6 +20,49 @@ export interface AdUnit { bids?: Bid[]; } +/** Minimal shape of a server-side auction slot injected into `window.tsjs.adSlots`. */ +export interface AuctionSlot { + id: string; + gam_unit_path: string; + div_id: string; + formats: Array<[number, number]>; + targeting?: Record; +} + +/** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ +export interface AuctionDebugBidData { + slot_id?: string; + price?: number | null; + currency?: string; + creative?: string | null; + adomain?: string[] | null; + bidder?: string; + width?: number; + height?: number; + nurl?: string | null; + burl?: string | null; + ad_id?: string | null; + cache_id?: string | null; + cache_host?: string | null; + cache_path?: string | null; + metadata?: Record; +} + +/** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ +export interface AuctionBidData { + hb_pb?: string; + hb_bidder?: string; + hb_adid?: string; + hb_cache_host?: string; + hb_cache_path?: string; + nurl?: string; + burl?: string; + /** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */ + adm?: string; + /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ + debug_bid?: AuctionDebugBidData; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -41,4 +84,20 @@ export interface TsjsApi { error(...args: unknown[]): void; debug(...args: unknown[]): void; }; + + // ── Server-side auction runtime (populated by TS edge injection) ────────── + /** Ad slot definitions injected at open. */ + adSlots?: AuctionSlot[]; + /** Winning bid targeting data injected before . */ + bids?: Record; + /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ + adInit?: () => void; + /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ + prevGptSlots?: unknown[]; + /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ + servicesEnabled?: boolean; + /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ + divToSlotId?: Record; + /** Guards SPA pushState hook installation. */ + spaHookInstalled?: boolean; } diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 87455591..af57772b 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -1,4 +1,23 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Track every 'message' EventListener added to window across the entire test +// file. This lets the installTsRenderBridge suite remove all accumulated +// handlers (registered by each vi.resetModules() + import('./index') in the +// installTsAdInit suite) before dispatching its own events. +const allMessageHandlers: EventListener[] = []; +const _origWindowAddEventListener = window.addEventListener.bind(window); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).addEventListener = function ( + type: string, + handler: EventListenerOrEventListenerObject, + options?: unknown +) { + if (type === 'message') { + allMessageHandlers.push(handler as EventListener); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return _origWindowAddEventListener(type, handler as EventListener, options as any); +}; interface SlotRenderEvent { isEmpty: boolean; @@ -10,25 +29,19 @@ interface SlotRenderEvent { type TestWindow = Window & { googletag?: unknown; - __ts_ad_slots?: unknown; - __ts_bids?: unknown; - __tsAdInit?: () => void; - __tsPrevGptSlots?: unknown; - __tsServicesEnabled?: boolean; - __tsSpaHookInstalled?: boolean; - __tsDivToSlotId?: Record; + apstag?: { setDisplayBids?: () => void }; + // Typed as `any` to avoid the TypeScript intersection with the global + // Window.tsjs declaration (TsjsApi from core/types.ts), which would require + // every test fixture to satisfy the full TsjsApi shape. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tsjs?: any; }; describe('installTsAdInit', () => { beforeEach(() => { vi.resetModules(); - delete (window as TestWindow).__ts_ad_slots; - delete (window as TestWindow).__ts_bids; - delete (window as TestWindow).__tsAdInit; - delete (window as TestWindow).__tsPrevGptSlots; - delete (window as TestWindow).__tsSpaHookInstalled; - delete (window as TestWindow).__tsDivToSlotId; - (window as TestWindow).__tsServicesEnabled = false; + const tw = window as TestWindow; + delete tw.tsjs; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { Object.defineProperty(navigator, 'sendBeacon', { @@ -37,9 +50,20 @@ describe('installTsAdInit', () => { configurable: true, }); } + // adInit now queries the DOM for div elements by id/prefix — create the + // test div so getElementById and querySelector both resolve correctly. + if (!document.getElementById('div-atf-sidebar')) { + const div = document.createElement('div'); + div.id = 'div-atf-sidebar'; + document.body.appendChild(div); + } + }); + + afterEach(() => { + document.getElementById('div-atf-sidebar')?.remove(); }); - it('reads window.__ts_bids synchronously and applies bid targeting before refresh', async () => { + it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -48,6 +72,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), addEventListener: vi.fn(), refresh: vi.fn(), }; @@ -57,40 +82,103 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc-uuid', + hb_cache_host: 'cache.example.com', + hb_cache_path: '/pbc/v1/cache', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; const fetchSpy = vi.spyOn(global, 'fetch'); const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(fetchSpy).not.toHaveBeenCalled(); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'abc-uuid'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); expect(mockPubads.refresh).toHaveBeenCalled(); fetchSpy.mockRestore(); }); + it('keeps the GAM path when debug adm is present', async () => { + const slotEl = document.getElementById('div-atf-sidebar')!; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue(['debug-uuid']), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const destroySlots = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + destroySlots, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '0.20', + hb_bidder: 'mocktioneer', + hb_adid: 'debug-uuid', + adm: '
Debug creative
', + }, + }, + }; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(slotEl.innerHTML).toBe(''); + expect(destroySlots).not.toHaveBeenCalledWith([mockSlot]); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '0.20'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + }); + it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -103,6 +191,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -114,28 +203,30 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(capturedListener).toBeDefined(); capturedListener!({ isEmpty: false, slot: mockSlot }); @@ -157,6 +248,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -168,27 +260,29 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - nurl: 'https://aps/win', - burl: 'https://aps/bill', + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.50', + hb_bidder: 'aps', + nurl: 'https://aps/win', + burl: 'https://aps/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(capturedListener).toBeDefined(); capturedListener!({ isEmpty: false, slot: mockSlot }); @@ -215,6 +309,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlotNoMatch]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -226,28 +321,30 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); expect(beaconSpy).not.toHaveBeenCalled(); @@ -270,6 +367,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -281,22 +379,24 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); capturedListener!({ isEmpty: false, slot: arenaSlot }); @@ -304,9 +404,110 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); - it('calls refresh even when __ts_bids is empty (graceful fallback)', async () => { + it('calls apstag.setDisplayBids when hb_bidder is aps', async () => { + const setDisplayBidsSpy = vi.fn(); + (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { hb_pb: '1.50', hb_bidder: 'aps', nurl: '', burl: '' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(setDisplayBidsSpy).toHaveBeenCalled(); + + delete (window as TestWindow).apstag; + }); + + it('does not call apstag.setDisplayBids when hb_bidder is not aps', async () => { + const setDisplayBidsSpy = vi.fn(); + (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(setDisplayBidsSpy).not.toHaveBeenCalled(); + + delete (window as TestWindow).apstag; + }); + + it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { + const emptyTestSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([emptyTestSlot]), addEventListener: vi.fn(), refresh: vi.fn(), }; @@ -319,21 +520,208 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = {}; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(mockPubads.refresh).toHaveBeenCalled(); }); }); + +describe('installTsRenderBridge', () => { + let fetchStub: ReturnType; + + beforeEach(() => { + vi.resetModules(); + // Remove ALL accumulated 'message' handlers from previous test module imports + // to prevent stale bridge listeners from intercepting our test event. + for (const handler of allMessageHandlers) { + window.removeEventListener('message', handler); + } + allMessageHandlers.length = 0; + + fetchStub = vi.fn(); + vi.stubGlobal('fetch', fetchStub); + + (window as TestWindow).tsjs = { + bids: { + homepage_header: { + hb_adid: 'test-cache-uuid', + hb_bidder: 'kargo', + hb_pb: '1.50', + hb_cache_host: 'openads.example.com', + hb_cache_path: '/cache', + }, + }, + adSlots: [ + { + id: 'homepage_header', + formats: [[728, 90]] as [number, number][], + gam_unit_path: '/a/b/c', + div_id: 'div-header', + targeting: {}, + }, + ], + }; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete (window as TestWindow).tsjs; + }); + + it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { + const mockAd = '
Test Creative
'; + fetchStub.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockAd), + } as Response); + + // Capture the bridge's 'message' listener at module-init time. + let bridgeListener: ((e: MessageEvent) => unknown) | undefined; + const origAdd = window.addEventListener.bind(window); + const addSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation( + (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { + if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + origAdd(type, handler as EventListener, opts as any); + } + ); + await import('./index'); + addSpy.mockRestore(); // Restore only addEventListener — fetchStub must stay stubbed + + expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); + + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + + // Dispatch the fake event — bridge listener fires synchronously, then runs + // fire-and-forget fetch().then() chains asynchronously. + bridgeListener!( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [fakePort], + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + + // Flush microtasks so the fetch mock resolves and .then chains fire. + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).toHaveBeenCalledWith( + 'https://openads.example.com/cache?uuid=test-cache-uuid', + { mode: 'cors' } + ); + expect(stopSpy).toHaveBeenCalled(); + expect(portMessages).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + expect(parsed.message).toBe('Prebid Response'); + expect(parsed.adId).toBe('test-cache-uuid'); + expect(parsed.ad).toBe(mockAd); + }); + + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { + const debugAdm = '
Debug Creative
'; + (window as TestWindow).tsjs = { + bids: { + homepage_header: { + hb_adid: 'debug-adid', + hb_bidder: 'mocktioneer', + hb_pb: '0.20', + adm: debugAdm, + }, + }, + adSlots: [ + { + id: 'homepage_header', + formats: [[728, 90]] as [number, number][], + gam_unit_path: '/a/b/c', + div_id: 'div-header', + targeting: {}, + }, + ], + }; + + let bridgeListener: ((e: MessageEvent) => unknown) | undefined; + const origAdd = window.addEventListener.bind(window); + const addSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation( + (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { + if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + origAdd(type, handler as EventListener, opts as any); + } + ); + await import('./index'); + addSpy.mockRestore(); + + expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); + + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + + bridgeListener!( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), + ports: [fakePort], + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).not.toHaveBeenCalled(); + expect(stopSpy).toHaveBeenCalled(); + expect(portMessages).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + expect(parsed.message).toBe('Prebid Response'); + expect(parsed.adId).toBe('debug-adid'); + expect(parsed.ad).toBe(debugAdm); + expect(parsed.width).toBe(728); + expect(parsed.height).toBe(90); + }); + + it('ignores message when adId does not match any TS bid', async () => { + await import('./index'); + fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); + + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ message: 'Prebid Request', adId: 'unknown-id' }), + ports: [], + }) + ); + + await new Promise((r) => setTimeout(r, 100)); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it('ignores non-Prebid messages', async () => { + await import('./index'); + window.dispatchEvent( + new MessageEvent('message', { data: JSON.stringify({ message: 'Other' }) }) + ); + await new Promise((r) => setTimeout(r, 50)); + expect(fetchStub).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index e1a1ee26..f893148b 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -1,4 +1,5 @@ import { log } from '../../core/log'; +import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -23,6 +24,8 @@ import { installGptGuard } from './script_guard'; * - Rewrite ad-unit paths for A/B testing. */ +const TS_INITIAL_TARGETING_KEY = 'ts_initial' as const; + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -46,6 +49,7 @@ interface GoogleTagPubAdsService { enableSingleRequest(): void; addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; refresh(slots?: GoogleTagSlot[]): void; + getSlots?(): GoogleTagSlot[]; } interface GoogleTag { @@ -180,97 +184,113 @@ export function installGptShim(): boolean { } // ------------------------------------------------------------------ -// Trusted Server ad-init types +// Trusted Server ad-init // ------------------------------------------------------------------ -interface TsAdSlot { - id: string; - gam_unit_path: string; - div_id: string; - formats: Array; - targeting: Record; -} - -interface TsBidData { - hb_pb?: string; - hb_bidder?: string; - hb_adid?: string; - nurl?: string; - burl?: string; -} - -type TsWindow = Window & { - __ts_ad_slots?: TsAdSlot[]; - __ts_bids?: Record; - __tsAdInit?: () => void; - __tsPrevGptSlots?: GoogleTagSlot[]; - __tsServicesEnabled?: boolean; - __tsDivToSlotId?: Record; -}; - /** - * Install `window.__tsAdInit`. + * Install `window.tsjs.adInit`. * - * Reads `window.__ts_ad_slots` (injected at head-open) and `window.__ts_bids` + * Reads `window.tsjs.adSlots` (injected at head-open) and `window.tsjs.bids` * (injected before ) synchronously — no fetch, no Promise. Applies bid * targeting to GPT slots, sets the `ts_initial` sentinel, registers * `slotRenderEnded` to fire both nurl and burl via sendBeacon when our * specific Prebid bid wins the GAM line item match, then calls refresh(). * * Idempotent: destroys previously created TS-managed slots before redefining them, - * so it is safe to call again after SPA navigation updates `__ts_ad_slots`/`__ts_bids`. + * so it is safe to call again after SPA navigation updates `tsjs.adSlots`/`tsjs.bids`. */ export function installTsAdInit(): void { - const w = window as TsWindow; - w.__tsAdInit = function () { - const slots = w.__ts_ad_slots ?? []; - const bids = w.__ts_bids ?? {}; + const ts = (window.tsjs ??= {} as TsjsApi); + ts.adInit = function () { + const slots = ts.adSlots ?? []; + // Snapshot bids at adInit() call time — correct for targeting setup. + // The slotRenderEnded listener below reads ts.bids live so SPA navigation + // updates (new ts.bids injected before ) are picked up at render time. + const bids = ts.bids ?? {}; const g = (window as GptWindow).googletag; if (!g) return; g.cmd?.push(() => { // Destroy previously defined TS slots before redefining for the new page. - if (w.__tsPrevGptSlots && w.__tsPrevGptSlots.length > 0) { - g.destroySlots?.(w.__tsPrevGptSlots); - w.__tsPrevGptSlots = []; + if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); + ts.prevGptSlots = []; } + // Slots TS defined itself — tracked for SPA destroy. Publisher-owned + // slots are reused but never destroyed by TS on navigation. const newSlots: GoogleTagSlot[] = []; + // All slots to refresh (TS-defined + publisher-owned reused). + const slotsToRefresh: GoogleTagSlot[] = []; const divToSlotId: Record = {}; slots.forEach((slot) => { - const gptSlot = g.defineSlot?.( - slot.gam_unit_path, - slot.formats as Array, - slot.div_id - ); - if (!gptSlot) return; - gptSlot.addService(g.pubads!()); - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); + // Resolve actual div ID: exact match first, then prefix query. + // div_id in config may be a stable prefix (e.g. "ad-header-0-") when + // the suffix is dynamically generated by the framework at render time. + const el = + document.getElementById(slot.div_id) ?? + document.querySelector(`[id^='${slot.div_id}']:not([id$='-container'])`); + if (!el) return; + const actualDivId = el.id; const bid = bids[slot.id] ?? {}; - (['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, bid[key]!); - }); - gptSlot.setTargeting('ts_initial', '1'); - divToSlotId[slot.div_id] = slot.id; - newSlots.push(gptSlot); + + const existingSlot = g.pubads!() + .getSlots?.() + ?.find?.((s: GoogleTagSlot) => s.getSlotElementId() === actualDivId); + let gptSlot: GoogleTagSlot; + let tsOwned = false; + if (existingSlot) { + gptSlot = existingSlot; + } else { + // Use outer container div for TS's slot when publisher hasn't defined + // theirs yet — keeps both slots on separate divs so publisher's + // later defineSlot on the inner div doesn't conflict. + const containerEl = document.getElementById(`${actualDivId}-container`); + const slotDivId = containerEl?.id ?? actualDivId; + const defined = g.defineSlot?.(slot.gam_unit_path, slot.formats, slotDivId); + if (!defined) return; + defined.addService(g.pubads!()); + gptSlot = defined; + tsOwned = true; + } + + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); + (['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'] as const).forEach( + (key) => { + if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); + } + ); + gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + divToSlotId[actualDivId] = slot.id; + if (tsOwned) newSlots.push(gptSlot); + slotsToRefresh.push(gptSlot); + + // APS: signal to apstag that bids are ready so Amazon's GAM creative + // can render. apstag must already be initialised on the page (which it + // is on production publisher pages). Safe no-op if apstag is absent. + if (bid.hb_bidder === 'aps' || bid.hb_bidder === 'amazon-aps') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).apstag?.setDisplayBids?.(); + } }); - w.__tsPrevGptSlots = newSlots; + ts.prevGptSlots = newSlots as unknown[]; // Replace (not merge) so destroyed slots from previous navigation don't linger. - w.__tsDivToSlotId = divToSlotId; + ts.divToSlotId = divToSlotId; // enableSingleRequest and enableServices must only be called once per page load. - if (!w.__tsServicesEnabled) { + if (!ts.servicesEnabled) { g.pubads!().enableSingleRequest(); g.enableServices?.(); - w.__tsServicesEnabled = true; + ts.servicesEnabled = true; g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { const divId: string = event.slot?.getSlotElementId?.() ?? ''; - const slotId = (w.__tsDivToSlotId ?? {})[divId]; + const slotId = (ts.divToSlotId ?? {})[divId]; if (!slotId) return; - const bid = (w.__ts_bids ?? {})[slotId] ?? {}; + // Read ts.bids live (not the snapshot above) so post-navigation bid data is used. + const bid = (ts.bids ?? {})[slotId] ?? {}; // Prebid: compare hb_adid targeting to verify the specific creative won. // APS: no hb_adid equivalent — fires if bidder exists and slot is non-empty. // Known limitation: APS path may over-fire if a non-APS line item wins. @@ -286,16 +306,16 @@ export function installTsAdInit(): void { }); } - if (newSlots.length > 0) { - g.pubads!().refresh(newSlots); + if (slotsToRefresh.length > 0) { + g.pubads!().refresh(slotsToRefresh); } }); }; } interface PageBidsResponse { - slots: TsAdSlot[]; - bids: Record; + slots: AuctionSlot[]; + bids: Record; } /** @@ -304,15 +324,15 @@ interface PageBidsResponse { * Patches `history.pushState` and `history.replaceState`, and listens to * `popstate`, so that after each client-side route change the trusted server * fetches fresh slots + bids from `/__ts/page-bids?path=`, updates - * `window.__ts_ad_slots` / `window.__ts_bids`, and calls `window.__tsAdInit()`. + * `window.tsjs.adSlots` / `window.tsjs.bids`, and calls `window.tsjs.adInit()`. * - * Idempotent: guarded by `window.__tsSpaHookInstalled` so multiple calls are safe. + * Idempotent: guarded by `window.tsjs.spaHookInstalled` so multiple calls are safe. */ export function installSpaAuctionHook(): void { if (typeof window === 'undefined') return; - const win = window as TsWindow & { __tsSpaHookInstalled?: boolean }; - if (win.__tsSpaHookInstalled) return; - win.__tsSpaHookInstalled = true; + const ts = (window.tsjs ??= {} as TsjsApi); + if (ts.spaHookInstalled) return; + ts.spaHookInstalled = true; let inflight: AbortController | null = null; @@ -329,9 +349,9 @@ export function installSpaAuctionHook(): void { if (!res.ok) return; const data = (await res.json()) as PageBidsResponse; if (inflight !== controller) return; - win.__ts_ad_slots = data.slots; - win.__ts_bids = data.bids; - win.__tsAdInit?.(); + ts.adSlots = data.slots; + ts.bids = data.bids; + ts.adInit?.(); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; log.warn('SPA auction hook: fetch failed', err); @@ -360,12 +380,11 @@ export function installSpaAuctionHook(): void { /** * Register the slim-Prebid lazy loader. Fires after window.load — off the - * critical path. slim-Prebid handles refresh auctions and userID module - * warm-up (ID5, sharedID, LiveRamp ATS, Lockr). It skips initial-render slots - * (ts_initial=1) and registers as the GPT refresh handler for scroll/sticky auctions. + * critical path. Slim-Prebid handles scroll/refresh auctions and userID + * module warm-up (ID5, sharedID, LiveRamp ATS, Lockr). * - * Phase 1: no-op unless window.__tsjs_slim_prebid_url is set (it won't be until - * the slim-Prebid bundle build target ships in a later phase). + * Phase 1: no-op unless `window.__tsjs_slim_prebid_url` is set (the slim + * bundle build target ships in a later phase). */ export function installSlimPrebidLoader(): void { const url = (window as GptWindow).__tsjs_slim_prebid_url; @@ -378,6 +397,114 @@ export function installSlimPrebidLoader(): void { }); } +/** Minimal display renderer injected into the ad iframe by pbRender. */ +const TS_DISPLAY_RENDERER = + '(function(){window.render=function(d,h,w){' + + 'var f=h.mkFrame(w.document,{width:d.width||"100%",height:d.height||"100%"});' + + 'if(d.adUrl&&!d.ad){f.src=d.adUrl;}else{f.srcdoc=d.ad;}' + + 'w.document.body.appendChild(f);};})();'; + +/** + * Install the TS → pbRender bridge. + * + * Must be installed synchronously at module init — before `adInit()` fires + * `refresh()`, which triggers GAM to serve the Prebid creative. Installing + * post-load would miss first-impression `"Prebid Request"` messages. + * + * When `adId` matches a TS server-side bid in `window.tsjs.bids` AND the bid + * has renderable markup, the bridge: + * 1. Uses debug `adm` directly when present, otherwise fetches from PBS Cache. + * 2. Replies via the MessageChannel port with a `"Prebid Response"`. + * 3. Calls `stopImmediatePropagation()` so Prebid.js does not also process + * the message and log spurious failures. + * + * Lives in gpt/index.ts (not prebid/index.ts) to avoid pulling the full + * Prebid bundle into tsjs-gpt.js via inlineDynamicImports. + */ +export function installTsRenderBridge(): void { + if (typeof window === 'undefined') return; + + window.addEventListener('message', (e: MessageEvent) => { + let data: Record; + try { + data = + typeof e.data === 'object' + ? (e.data as Record) + : (JSON.parse(e.data as string) as Record); + } catch { + return; + } + + if (data['message'] !== 'Prebid Request') return; + const adId = data['adId'] as string | undefined; + if (!adId) return; + + const port = e.ports?.[0]; + if (!port) return; + + // Build reverse map adId → slotId from live window.tsjs.bids. + const bids = window.tsjs?.bids ?? {}; + let slotId: string | undefined; + let matchedBid: (typeof bids)[string] | undefined; + for (const [sid, bid] of Object.entries(bids)) { + if (bid.hb_adid === adId) { + slotId = sid; + matchedBid = bid; + break; + } + } + + // Not a TS bid — let Prebid.js handle it. + if (!slotId || !matchedBid) return; + + const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); + const [width, height] = slot?.formats?.[0] ?? [728, 90]; + + if (matchedBid.adm) { + e.stopImmediatePropagation(); + port.postMessage( + JSON.stringify({ + message: 'Prebid Response', + adId, + ad: matchedBid.adm, + renderer: TS_DISPLAY_RENDERER, + width, + height, + }) + ); + log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from debug adm`); + return; + } + + // No TS render source — let Prebid.js handle it. + if (!matchedBid.hb_cache_host || !matchedBid.hb_cache_path) return; + + // TS owns this adId — stop Prebid from also processing it. + e.stopImmediatePropagation(); + + const cacheUrl = `https://${matchedBid.hb_cache_host}${matchedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; + + fetch(cacheUrl, { mode: 'cors' }) + .then((res) => (res.ok ? res.text() : Promise.reject(res.status))) + .then((ad) => { + port.postMessage( + JSON.stringify({ + message: 'Prebid Response', + adId, + ad, + renderer: TS_DISPLAY_RENDERER, + width, + height, + }) + ); + log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); + }) + .catch((err) => { + log.warn(`[tsjs-gpt] pbRender bridge: PBS Cache fetch failed for '${slotId}'`, err); + }); + }); +} + // Register the activation function on `window` so the server-injected inline // script can call it explicitly. The server emits: // `. + /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, /// Shared auction result — written by auction task before HTML processing begins. /// Handler reads this in `el.on_end_tag()` on the body element. - /// `None` means no auction ran; inject empty `__ts_bids = {}` as fallback. + /// `None` means no auction ran; inject empty `tsjs.bids = {}` as fallback. pub ad_bids_state: std::sync::Arc>>, } @@ -311,10 +311,10 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) } }), - // Inject __ts_bids before via end_tag_handlers — only when + // Inject tsjs.bids before via end_tag_handlers — only when // slots matched this URL. When no slots matched, skip injection entirely // so the publisher's existing client-side Prebid/GPT flow is unmodified - // (dual-mode rollout: calling __tsAdInit with empty slots would invoke + // (dual-mode rollout: calling tsjs.adInit with empty slots would invoke // enableSingleRequest/enableServices and conflict with the publisher's GPT init). // Guard with AtomicBool so the script is only injected once even if // the origin HTML contains multiple elements (e.g. template fragments). @@ -1278,7 +1278,8 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: Some( - r#""#.to_string(), + r#""# + .to_string(), ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), }; @@ -1291,8 +1292,12 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window.__ts_ad_slots"), - "should inject ad slots at head-open" + html.contains("window.tsjs=window.tsjs||{}"), + "should inject ad slots namespace at head-open" + ); + assert!( + html.contains(".adSlots=JSON.parse"), + "should inject adSlots at head-open" ); assert!( !html.contains("__ts_request_id"), @@ -1302,15 +1307,16 @@ mod tests { #[test] fn injects_ts_bids_before_body_close() { - let bids_script = - r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1319,27 +1325,32 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window.__ts_bids"), + html.contains("window.tsjs=window.tsjs||{}"), + "should inject _ts namespace for bids before " + ); + assert!( + html.contains(".bids=JSON.parse"), "should inject bids before " ); let bids_pos = html - .find("window.__ts_bids") - .expect("bids should be in output"); + .find("window.tsjs=window.tsjs||{}") + .expect("bids namespace should be in output"); let body_close_pos = html.find("").expect(" should be in output"); assert!(bids_pos < body_close_pos, "bids must appear before "); } #[test] fn injects_ts_bids_only_once_with_multiple_body_elements() { - let bids_script = - r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1349,9 +1360,9 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert_eq!( - html.matches("window.__ts_bids").count(), + html.matches(".bids=JSON.parse").count(), 1, - "should inject __ts_bids exactly once even with multiple elements" + "should inject tsjs.bids exactly once even with multiple elements" ); } @@ -1365,7 +1376,9 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1374,14 +1387,14 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("__ts_bids=JSON.parse(\"{}\")"), + html.contains(".bids=JSON.parse(\"{}\")"), "should inject empty bids fallback when auction produced nothing" ); } #[test] fn does_not_inject_ts_bids_when_no_slots_matched() { - // No slots matched this URL — ad_slots_script is None. __ts_bids must be + // No slots matched this URL — ad_slots_script is None. tsjs.bids must be // omitted entirely so the publisher's existing client-side GPT flow is // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). let state = std::sync::Arc::new(std::sync::Mutex::new(None)); @@ -1399,8 +1412,8 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - !html.contains("__ts_bids"), - "should NOT inject __ts_bids when no slots matched" + !html.contains(".bids=JSON.parse"), + "should NOT inject tsjs.bids when no slots matched" ); } } diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index beacef1d..483e4499 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -89,7 +89,7 @@ impl IntegrationConfig for AdServerMockConfig { // ============================================================================ /// Lookup index built from original SSP bids during `request_bids`, consumed -/// during `parse_response` to restore `nurl`/`burl`/`ad_id` that the mock +/// during `parse_response` to restore render/accounting fields that the mock /// mediator endpoint does not echo back. /// /// Keyed by `(provider_name, slot_id, bidder_name)`. @@ -98,7 +98,7 @@ type BidIndex = HashMap<(String, String, String), Bid>; /// Mock ad server mediator provider. pub struct AdServerMockProvider { config: AdServerMockConfig, - /// Bridges SSP bid metadata (`nurl`/`burl`/`ad_id`) from `request_bids` to `parse_response`. + /// Bridges SSP bid metadata from `request_bids` to `parse_response`. bid_index: Mutex>, } @@ -226,7 +226,7 @@ impl AdServerMockProvider { /// Mediation returns decoded prices for all bids (including APS bids that were encoded). /// /// `bid_index` is the SSP-bid lookup built in `request_bids`. The mock mediator - /// does not echo `nurl`/`burl`/`ad_id` back, so they are restored from the index + /// does not echo render/accounting fields back, so they are restored from the index /// using `(seat, impid, bidder)` where bidder is recovered from the echoed `crid` /// field (`"{bidder}-creative"` format set during request construction). fn parse_mediation_response( @@ -249,16 +249,18 @@ impl AdServerMockProvider { let slot_id = bid["impid"].as_str().unwrap_or("").to_string(); // Recover bidder name from crid ("{bidder}-creative") to look up the - // original SSP bid and restore nurl/burl/ad_id the mediator drops. + // original SSP bid and restore render/accounting fields the mediator drops. let crid = bid["crid"].as_str().unwrap_or(""); let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| { log::debug!( - "adserver_mock: crid '{crid}' does not match '-creative' — dropping nurl/burl/ad_id" + "adserver_mock: crid '{crid}' does not match '-creative'; render/accounting fields may be missing" ); "" }); let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string()); let original = bid_index.get(&key); + let restored_bidder = + original.map_or_else(|| seat_name.to_string(), |b| b.bidder.clone()); let width = bid["w"].as_u64().unwrap_or(0) as u32; let height = bid["h"].as_u64().unwrap_or(0) as u32; @@ -276,7 +278,7 @@ impl AdServerMockProvider { creative: bid["adm"].as_str().map(String::from), width, height, - bidder: seat_name.to_string(), + bidder: restored_bidder, adomain: bid["adomain"].as_array().map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) @@ -285,6 +287,9 @@ impl AdServerMockProvider { nurl: original.and_then(|b| b.nurl.clone()), burl: original.and_then(|b| b.burl.clone()), ad_id: original.and_then(|b| b.ad_id.clone()), + cache_id: original.and_then(|b| b.cache_id.clone()), + cache_host: original.and_then(|b| b.cache_host.clone()), + cache_path: original.and_then(|b| b.cache_path.clone()), metadata: HashMap::new(), }); } @@ -563,6 +568,9 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }], response_time_ms: 150, @@ -583,6 +591,9 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }], response_time_ms: 120, @@ -656,6 +667,98 @@ mod tests { assert_eq!(bid.height, 90); } + #[test] + fn parse_mediation_response_restores_original_bid_render_fields() { + let provider = AdServerMockProvider::new(AdServerMockConfig::default()); + let mediation_response = json!({ + "id": "test-auction-123", + "seatbid": [ + { + "seat": "prebid", + "bid": [ + { + "id": "mediated-bid-001", + "impid": "header-banner", + "price": 0.20, + "adm": "
Mediated Ad
", + "w": 728, + "h": 90, + "crid": "mocktioneer-creative", + "adomain": ["example.com"] + } + ] + } + ], + "cur": "USD" + }); + let mut bid_index = BidIndex::new(); + bid_index.insert( + ( + "prebid".to_string(), + "header-banner".to_string(), + "mocktioneer".to_string(), + ), + Bid { + slot_id: "header-banner".to_string(), + price: Some(0.20), + currency: "USD".to_string(), + creative: Some("
Original Ad
".to_string()), + adomain: Some(vec!["example.com".to_string()]), + bidder: "mocktioneer".to_string(), + width: 728, + height: 90, + nurl: Some("https://ssp.example/win".to_string()), + burl: Some("https://ssp.example/bill".to_string()), + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("cache-uuid".to_string()), + cache_host: Some("cache.example".to_string()), + cache_path: Some("/cache".to_string()), + metadata: HashMap::new(), + }, + ); + + let auction_response = + provider.parse_mediation_response(&mediation_response, 42, &bid_index); + + assert_eq!(auction_response.status, BidStatus::Success); + assert_eq!(auction_response.bids.len(), 1); + let bid = &auction_response.bids[0]; + assert_eq!( + bid.bidder, "mocktioneer", + "should preserve underlying bidder for hb_bidder targeting" + ); + assert_eq!( + bid.nurl.as_deref(), + Some("https://ssp.example/win"), + "should restore nurl" + ); + assert_eq!( + bid.burl.as_deref(), + Some("https://ssp.example/bill"), + "should restore burl" + ); + assert_eq!( + bid.ad_id.as_deref(), + Some("bid-impression-id"), + "should restore ad_id" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid"), + "should restore PBS cache UUID" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("cache.example"), + "should restore PBS cache host" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should restore PBS cache path" + ); + } + #[test] fn test_parse_empty_mediation_response() { let config = AdServerMockConfig::default(); @@ -727,6 +830,9 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: aps_metadata, }], response_time_ms: 100, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 304f61a0..d1c449bf 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -451,6 +451,9 @@ impl ApsAuctionProvider { nurl: None, // Real APS uses client-side event tracking burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata, }) } diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index cb099402..5f88f69c 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -81,6 +81,16 @@ pub struct GptConfig { /// Whether to rewrite GPT script URLs in publisher HTML. #[serde(default = "default_rewrite_script")] pub rewrite_script: bool, + + /// URL for the slim-Prebid bundle loaded post-window.load. + /// + /// When set, `installSlimPrebidLoader()` in the GPT bundle will load this + /// script after `window.load`, enabling scroll/refresh client-side auctions + /// and userID module warm-up. Set to the publisher's tsjs-prebid bundle URL. + /// + /// Override via env var: `TRUSTED_SERVER__INTEGRATIONS__GPT__SLIM_PREBID_URL` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slim_prebid_url: Option, } impl IntegrationConfig for GptConfig { @@ -437,11 +447,11 @@ impl IntegrationHeadInjector for GptIntegration { GPT_INTEGRATION_ID } - /// Injects the `__tsAdInit` bootstrap script into ``. + /// Injects the `tsjs.adInit` bootstrap script into ``. /// /// ## Scroll / refresh handoff contract (Phase 1) /// - /// `__tsAdInit` handles **initial render only**: it wires server-side bid + /// `tsjs.adInit` handles **initial render only**: it wires server-side bid /// targeting into GPT slots and fires win beacons (`nurl`/`burl`) via /// `slotRenderEnded`. It does **not** trigger refresh auctions or handle /// GPT slot refresh events. @@ -451,22 +461,31 @@ impl IntegrationHeadInjector for GptIntegration { /// impressions. SPA pushState navigation is also slim-Prebid's domain. /// The `POST /auction` endpoint is not involved in scroll or refresh flows. fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - vec![ + let mut scripts = vec![ "" .to_string(), format!("", GPT_BOOTSTRAP_JS), - ] + ]; + + if let Some(ref url) = self.config.slim_prebid_url { + scripts.push(format!( + "", + serde_json::to_string(url).expect("should serialize string") + )); + } + + scripts } } -/// Inline `window.__tsAdInit` bootstrap injected at `` so the bids +/// Inline `window.tsjs.adInit` bootstrap injected at `` so the bids /// script at `` can call it before the TSJS bundle has loaded. /// /// The bundle's idempotent implementation in /// `crates/js/lib/src/integrations/gpt/index.ts` later overwrites this stub. /// Both implementations guard the one-time-per-page setup with -/// `window.__tsServicesEnabled` so neither double-enables services if the +/// `window.tsjs.servicesEnabled` so neither double-enables services if the /// publisher's own init code also calls `googletag.enableServices()`. const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); @@ -502,6 +521,7 @@ mod tests { script_url: default_script_url(), cache_ttl_seconds: 3600, rewrite_script: true, + slim_prebid_url: None, } } @@ -1062,10 +1082,10 @@ mod tests { }; let inserts = integration.head_inserts(&ctx); let combined = inserts.join(""); - assert!(combined.contains("__tsAdInit"), "should define __tsAdInit"); + assert!(combined.contains("ts.adInit"), "should define tsjs.adInit"); assert!( - combined.contains("window.__ts_bids"), - "should read window.__ts_bids synchronously" + combined.contains("ts.bids"), + "should read tsjs.bids synchronously" ); assert!( combined.contains("ts_initial"), @@ -1110,13 +1130,10 @@ mod tests { }; let combined = integration.head_inserts(&ctx).join(""); assert!( - combined.contains("__tsServicesEnabled"), - "should guard enableServices/enableSingleRequest with the __tsServicesEnabled flag" - ); - assert!( - combined.contains("window.__tsAdInit"), - "should install __tsAdInit on window" + combined.contains("ts.servicesEnabled"), + "should guard enableServices/enableSingleRequest with the tsjs.servicesEnabled flag" ); + assert!(combined.contains("ts.adInit"), "should install tsjs.adInit"); assert!( !combined.contains("googletag.pubads().refresh()"), "should never call unbounded refresh() — only refresh(newSlots)" @@ -1131,4 +1148,59 @@ mod tests { "gpt" ); } + + #[test] + fn head_inserts_emits_slim_prebid_url_when_configured() { + let config = GptConfig { + slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()), + ..test_config() + }; + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 3, + "should emit three head inserts when slim_prebid_url is set" + ); + assert_eq!( + inserts[2], + r#""#, + "should emit the slim-Prebid URL as a JSON-encoded string assignment" + ); + } + + #[test] + fn head_inserts_omits_slim_prebid_url_when_not_configured() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 2, + "should emit exactly two head inserts when slim_prebid_url is absent" + ); + assert!( + inserts + .iter() + .all(|s| !s.contains("__tsjs_slim_prebid_url")), + "should not emit slim-Prebid URL tag when not configured" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 85109d72..0c7ea0dd 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -1,88 +1,108 @@ // Edge-injected GPT auction bootstrap. // -// This is the minimal `window.__tsAdInit` that runs on first page load +// This is the minimal `window.tsjs.adInit` that runs on first page load // before the TSJS bundle has had a chance to install its richer // idempotent implementation. The bundle in -// crates/js/lib/src/integrations/gpt/index.ts overwrites `__tsAdInit` +// crates/js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` // once it loads. // // Contract with the bundle: -// - Both implementations must set `window.__tsServicesEnabled = true` +// - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a -// subsequent call from any source (the bundle's `__tsAdInit`, the -// publisher's own GPT init code) becomes a no-op. +// subsequent call becomes a no-op. // - `refresh()` is called only for the slots defined in this pass, -// never the global slot list, so we never accidentally refresh -// publisher-managed slots that we don't own. +// never the global slot list. // -// Only installed if `window.__tsAdInit` isn't already defined — that -// way the bundle (or anything else) can preempt this fallback by -// installing first. +// Only installed if `window.tsjs.adInit` isn't already defined. (function () { - if (typeof window === "undefined" || window.__tsAdInit) { - return; - } - window.__tsAdInit = function () { - var slots = window.__ts_ad_slots || []; - var bids = window.__ts_bids || {}; + if (typeof window === "undefined") return; + var ts = (window.tsjs = window.tsjs || {}); + if (ts.adInit) return; + + ts.adInit = function () { + var slots = ts.adSlots || []; + var bids = ts.bids || {}; var divToSlotId = {}; + googletag.cmd.push(function () { + // Slots TS defined itself — tracked for SPA destroy. Publisher-owned + // slots are reused but never destroyed by TS on navigation. var newSlots = []; + // All slots to refresh (TS-defined + publisher-owned reused). + var slotsToRefresh = []; slots.forEach(function (slot) { - var s = googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - slot.div_id, - ); - if (!s) return; - s.addService(googletag.pubads()); + // Resolve actual div ID: exact match first, then prefix query. + // div_id in config may be a stable prefix (e.g. "ad-header-0-") when + // the suffix is dynamically generated by the framework at render time. + var el = + document.getElementById(slot.div_id) || + document.querySelector( + "[id^='" + slot.div_id + "']:not([id$='-container'])", + ); + if (!el) return; + var actualDivId = el.id; + var b = bids[slot.id] || {}; + + var existingSlots = googletag.pubads().getSlots(); + var s = + existingSlots.find(function (gs) { + return gs.getSlotElementId() === actualDivId; + }) || null; + var tsOwned = false; + if (!s) { + // Use outer container div for TS's slot when publisher hasn't defined + // theirs yet — keeps both slots on separate divs so publisher's + // later defineSlot on the inner div doesn't conflict. + var containerEl = document.getElementById(actualDivId + "-container"); + var slotDivId = containerEl ? containerEl.id : actualDivId; + s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slotDivId); + if (!s) return; + s.addService(googletag.pubads()); + tsOwned = true; + } + Object.entries(slot.targeting || {}).forEach(function (e) { s.setTargeting(e[0], e[1]); }); - var b = bids[slot.id] || {}; - ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { + [ + "hb_pb", + "hb_bidder", + "hb_adid", + "hb_cache_host", + "hb_cache_path", + ].forEach(function (k) { if (b[k]) s.setTargeting(k, b[k]); }); + // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - divToSlotId[slot.div_id] = slot.id; - newSlots.push(s); + divToSlotId[actualDivId] = slot.id; + if (tsOwned) newSlots.push(s); + slotsToRefresh.push(s); }); - // Expose slot metadata on window so later calls (SPA navigation, - // the bundle's __tsAdInit) can destroy stale slots and the render - // listener can resolve slot IDs after navigation updates these maps. - window.__tsPrevGptSlots = newSlots; - window.__tsDivToSlotId = divToSlotId; - // Guard the one-time-per-page setup so a follow-up call (e.g. - // publisher's own init code or the bundle's `__tsAdInit` after - // it overwrites this stub) doesn't double-enable services. - if (!window.__tsServicesEnabled) { + ts.prevGptSlots = newSlots; + ts.divToSlotId = divToSlotId; + if (!ts.servicesEnabled) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); - window.__tsServicesEnabled = true; - googletag - .pubads() - .addEventListener("slotRenderEnded", function (ev) { - var divId = ev.slot.getSlotElementId(); - // Read from window so SPA navigation updates are picked up; - // early-return for slots not managed by Trusted Server. - var slotId = (window.__tsDivToSlotId || {})[divId]; - if (!slotId) return; - var b = (window.__ts_bids || {})[slotId] || {}; - // Prebid: verify the specific creative via hb_adid targeting. - // APS: no hb_adid — fire if any TS bidder is present and slot is non-empty. - var ourBidWon = - !ev.isEmpty && - (b.hb_adid - ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid - : !!b.hb_bidder); - if (ourBidWon) { - if (b.nurl) navigator.sendBeacon(b.nurl); - if (b.burl) navigator.sendBeacon(b.burl); - } - }); + ts.servicesEnabled = true; + googletag.pubads().addEventListener("slotRenderEnded", function (ev) { + var divId = ev.slot.getSlotElementId(); + var slotId = (ts.divToSlotId || {})[divId]; + if (!slotId) return; + var b = (ts.bids || {})[slotId] || {}; + var ourBidWon = + !ev.isEmpty && + (b.hb_adid + ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid + : !!b.hb_bidder); + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl); + if (b.burl) navigator.sendBeacon(b.burl); + } + }); } - if (newSlots.length > 0) { - googletag.pubads().refresh(newSlots); + if (slotsToRefresh.length > 0) { + googletag.pubads().refresh(slotsToRefresh); } }); }; diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index b74b234c..1ab937ba 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -8,6 +8,7 @@ use fastly::http::{header, Method, StatusCode, Url}; use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; +use url::Url as ParsedUrl; use validator::Validate; use crate::auction::provider::AuctionProvider; @@ -1374,6 +1375,49 @@ impl PrebidAuctionProvider { .collect() }); + // Extract PBS Cache coordinates from ext.prebid.cache.bids + let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + + let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + + let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + ParsedUrl::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {}", e)) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + // path() returns "/" for root — only use if non-trivial + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { + None + } else { + Some(path) + }; + (host, path) + }) + .unwrap_or((None, None)); + + // Guard: if we extracted a cache UUID but couldn't extract the host, + // the bid will have hb_adid set but no endpoint to fetch from — creative will fail. + if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{}'", + slot_id + ); + } + Ok(AuctionBid { slot_id, price: Some(price), // Prebid provides decoded prices @@ -1386,6 +1430,9 @@ impl PrebidAuctionProvider { nurl, burl, ad_id, + cache_id, + cache_host, + cache_path, metadata: std::collections::HashMap::new(), }) } @@ -4339,4 +4386,137 @@ set = { networkId = 42 } "should fail fast when a canonical rule has no matcher fields" ); } + + #[test] + fn parse_bid_extracts_cache_id_from_ext_prebid_cache_bids() { + let bid_json = serde_json::json!({ + "id": "bid-id-123", + "impid": "atf_sidebar_ad", + "price": 1.50, + "adm": "
ad
", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "thetradedesk") + .expect("should parse bid"); + assert_eq!( + bid.cache_id.as_deref(), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should extract cacheId as cache_id" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("openads.adsrvr.org"), + "should extract host from cache URL" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should extract path from cache URL" + ); + } + + #[test] + fn parse_bid_sets_cache_fields_to_none_when_no_cache_entry() { + let bid_json = serde_json::json!({ + "id": "bid-id-456", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250 + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert!(bid.cache_id.is_none(), "should be None when cache absent"); + assert!(bid.cache_host.is_none(), "should be None when cache absent"); + assert!(bid.cache_path.is_none(), "should be None when cache absent"); + } + + #[test] + fn parse_bid_handles_malformed_cache_url_gracefully() { + let bid_json = serde_json::json!({ + "id": "bid-id-789", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "not-a-valid-url", + "cacheId": "some-uuid" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid without panicking"); + assert_eq!( + bid.cache_id.as_deref(), + Some("some-uuid"), + "should still extract cacheId even if URL is malformed" + ); + assert!( + bid.cache_host.is_none(), + "should be None when URL parse fails" + ); + assert!( + bid.cache_path.is_none(), + "should be None when URL parse fails" + ); + } + + #[test] + fn parse_bid_preserves_ad_id_alongside_cache_id() { + let bid_json = serde_json::json!({ + "id": "bid-impression-id", + "impid": "atf_sidebar_ad", + "adid": "bidder-ad-id-abc", + "price": 1.0, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://cache.example.com/cache", + "cacheId": "cache-uuid-xyz" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert_eq!( + bid.ad_id.as_deref(), + Some("bidder-ad-id-abc"), + "should keep ad_id from adid field" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid-xyz"), + "should extract cache UUID separately" + ); + } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c6ffa776..12a368c4 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -428,7 +428,7 @@ pub struct OwnedProcessResponseParams { /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. pub(crate) dispatched_auction: Option, - /// Price granularity used to bucket bids when building `__ts_bids`. + /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, } @@ -516,6 +516,7 @@ pub async fn stream_publisher_body_async( &result.winning_bids, params.price_granularity, ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -611,13 +612,14 @@ pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, + inject_adm: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity); + let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -757,7 +759,12 @@ async fn one_behind_loop( "one_behind_loop: collect complete — {} winning bid(s)", result.winning_bids.len() ); - write_bids_to_state(&result.winning_bids, price_granularity, ad_bids_state); + write_bids_to_state( + &result.winning_bids, + price_granularity, + ad_bids_state, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("stream", &result, ad_bids_state); @@ -853,7 +860,7 @@ pub async fn handle_publisher_request( integration_registry: &IntegrationRegistry, services: &RuntimeServices, orchestrator: &AuctionOrchestrator, - slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], mut req: Request, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -939,7 +946,7 @@ pub async fn handle_publisher_request( let is_bot = is_bot_user_agent(&req); let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { - crate::creative_opportunities::match_slots(&slots_file.slots, &request_path) + crate::creative_opportunities::match_slots(slots, &request_path) .into_iter() .cloned() .collect() @@ -1192,7 +1199,12 @@ pub async fn handle_publisher_request( "BufferedProcessed: auction collected — {} winning bid(s)", result.winning_bids.len() ); - write_bids_to_state(&result.winning_bids, price_granularity, &ad_bids_state); + write_bids_to_state( + &result.winning_bids, + price_granularity, + &ad_bids_state, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("buffered", &result, &ad_bids_state); @@ -1311,6 +1323,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, + include_adm: bool, ) -> serde_json::Map { winning_bids .iter() @@ -1323,10 +1336,30 @@ pub(crate) fn build_bid_map( "hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone()), ); - if let Some(ref ad_id) = bid.ad_id { + // hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses + // this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to + // bid.ad_id for APS and other non-PBS providers. + let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); + if let Some(id) = hb_adid { obj.insert( "hb_adid".to_string(), - serde_json::Value::String(ad_id.clone()), + serde_json::Value::String(id.to_string()), + ); + } + + // Cache endpoint coordinates — only present for PBS bids with Prebid Cache enabled. + // The Prebid Universal Creative constructs: + // https://?uuid= + if let Some(ref host) = bid.cache_host { + obj.insert( + "hb_cache_host".to_string(), + serde_json::Value::String(host.clone()), + ); + } + if let Some(ref path) = bid.cache_path { + obj.insert( + "hb_cache_path".to_string(), + serde_json::Value::String(path.clone()), ); } if let Some(ref nurl) = bid.nurl { @@ -1335,13 +1368,40 @@ pub(crate) fn build_bid_map( if let Some(ref burl) = bid.burl { obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); } + // Include raw creative markup only for explicit debug injection. + // The pbRender bridge can use it while PBS Cache is unavailable. + if include_adm { + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + obj.insert( + "debug_bid".to_string(), + serde_json::json!({ + "slot_id": bid.slot_id, + "price": bid.price, + "currency": bid.currency, + "creative": bid.creative, + "adomain": bid.adomain, + "bidder": bid.bidder, + "width": bid.width, + "height": bid.height, + "nurl": bid.nurl, + "burl": bid.burl, + "ad_id": bid.ad_id, + "cache_id": bid.cache_id, + "cache_host": bid.cache_host, + "cache_path": bid.cache_path, + "metadata": bid.metadata, + }), + ); + } (slot_id.clone(), serde_json::Value::Object(obj)) }) }) .collect() } -/// Build the `__ts_bids` `` sequences inside the string. @@ -1350,7 +1410,7 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map should be infallible"); let escaped = html_escape_for_script(&json); format!( - "", + "", escaped ) } @@ -1363,7 +1423,7 @@ pub(crate) fn build_empty_bids_script() -> String { build_bids_script(&serde_json::Map::new()) } -/// Build the `__ts_ad_slots` `", + "", escaped ) } @@ -1479,7 +1539,7 @@ pub async fn handle_page_bids( settings: &Settings, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, - slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], req: Request, ) -> Result> { let Some(co_config) = &settings.creative_opportunities else { @@ -1494,11 +1554,10 @@ pub async fn handle_page_bids( .map(|(_, v)| v.into_owned()) .unwrap_or_else(|| "/".to_string()); - let matched_slots: Vec<_> = - crate::creative_opportunities::match_slots(&slots_file.slots, &path_param) - .into_iter() - .cloned() - .collect(); + let matched_slots: Vec<_> = crate::creative_opportunities::match_slots(slots, &path_param) + .into_iter() + .cloned() + .collect(); let http_req = compat::from_fastly_headers_ref(&req); let request_info = @@ -1600,7 +1659,11 @@ pub async fn handle_page_bids( std::collections::HashMap::new() }; - let bid_map = build_bid_map(&winning_bids, co_config.price_granularity); + let bid_map = build_bid_map( + &winning_bids, + co_config.price_granularity, + settings.debug.inject_adm_for_testing, + ); let slots_json: Vec = matched_slots .iter() @@ -2582,6 +2645,7 @@ mod tests { gam_network_id: "21765378893".to_string(), auction_timeout_ms: Some(500), price_granularity: PriceGranularity::Dense, + slot: Vec::new(), } } @@ -2625,6 +2689,9 @@ mod tests { nurl: Some(nurl.to_string()), burl: Some(burl.to_string()), ad_id: Some(ad_id.to_string()), + cache_id: None, + cache_host: None, + cache_path: None, metadata: Default::default(), } } @@ -2635,11 +2702,15 @@ mod tests { let config = make_config(); let script = build_ad_slots_script(&slots, &config); assert!( - script.contains("window.__ts_ad_slots=JSON.parse"), - "should use JSON.parse" + script.contains("window.tsjs=window.tsjs||{}"), + "should initialise tsjs namespace" + ); + assert!( + script.contains(".adSlots=JSON.parse"), + "should use JSON.parse for adSlots" ); assert!(script.contains("atf_sidebar_ad"), "should include slot id"); - assert!(!script.contains("__ts_bids"), "must NOT contain bids"); + assert!(!script.contains("adInit"), "must NOT contain adInit"); assert!( !script.contains("__ts_request_id"), "must NOT contain request_id" @@ -2672,7 +2743,7 @@ mod tests { "https://ssp/bill", ), ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); let obj = entry.as_object().expect("should be object"); assert_eq!( @@ -2688,7 +2759,7 @@ mod tests { assert_eq!( obj.get("hb_adid").and_then(|v| v.as_str()), Some("abc123"), - "should include ad_id" + "should fall back to ad_id when no cache_id present" ); assert_eq!( obj.get("nurl").and_then(|v| v.as_str()), @@ -2702,6 +2773,250 @@ mod tests { ); } + #[test] + fn client_bid_map_omits_adm_by_default() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert!( + obj.get("adm").is_none(), + "should omit adm when debug injection is disabled" + ); + assert!( + obj.get("debug_bid").is_none(), + "should omit debug bid when debug injection is disabled" + ); + } + + #[test] + fn client_bid_map_includes_adm_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("adm").and_then(|v| v.as_str()), + Some("
Creative
"), + "should include adm when debug injection is enabled" + ); + } + + #[test] + fn client_bid_map_includes_debug_bid_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "mocktioneer", + "bid-ad-id", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + bid.adomain = Some(vec!["example.com".to_string()]); + bid.cache_id = Some("cache-uuid".to_string()); + bid.cache_host = Some("cache.example".to_string()); + bid.cache_path = Some("/cache".to_string()); + bid.metadata.insert( + "raw_field".to_string(), + serde_json::Value::String("raw-value".to_string()), + ); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + let debug_bid = obj + .get("debug_bid") + .and_then(|v| v.as_object()) + .expect("should include debug bid when debug injection is enabled"); + + assert_eq!( + debug_bid.get("slot_id").and_then(|v| v.as_str()), + Some("atf_sidebar_ad"), + "should expose original slot id" + ); + assert_eq!( + debug_bid.get("bidder").and_then(|v| v.as_str()), + Some("mocktioneer"), + "should expose original bidder" + ); + assert_eq!( + debug_bid.get("ad_id").and_then(|v| v.as_str()), + Some("bid-ad-id"), + "should expose original bid ad id" + ); + assert_eq!( + debug_bid.get("cache_id").and_then(|v| v.as_str()), + Some("cache-uuid"), + "should expose original PBS cache id" + ); + assert_eq!( + debug_bid.get("metadata").and_then(|v| v.get("raw_field")), + Some(&serde_json::Value::String("raw-value".to_string())), + "should expose provider metadata" + ); + } + + #[test] + fn bid_map_uses_cache_id_for_hb_adid_when_present() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), + cache_host: Some("openads.adsrvr.org".to_string()), + cache_path: Some("/cache".to_string()), + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should use cache_id for hb_adid, not ad_id" + ); + assert_eq!( + obj.get("hb_cache_host").and_then(|v| v.as_str()), + Some("openads.adsrvr.org"), + "should emit hb_cache_host" + ); + assert_eq!( + obj.get("hb_cache_path").and_then(|v| v.as_str()), + Some("/cache"), + "should emit hb_cache_path" + ); + } + + #[test] + fn bid_map_falls_back_to_ad_id_when_cache_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("aps-bid-token".to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("aps-bid-token"), + "should fall back to ad_id when cache_id absent" + ); + assert!( + obj.get("hb_cache_host").is_none(), + "should not emit hb_cache_host when absent" + ); + assert!( + obj.get("hb_cache_path").is_none(), + "should not emit hb_cache_path when absent" + ); + } + + #[test] + fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert!( + obj.get("hb_adid").is_none(), + "should omit hb_adid when no cache_id and no ad_id" + ); + } + #[test] fn bid_map_excludes_slot_when_price_is_none() { let mut winning_bids = HashMap::new(); @@ -2719,10 +3034,13 @@ mod tests { nurl: None, burl: None, ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); assert!( map.is_empty(), "slot with no price should be excluded from bid map" @@ -2789,9 +3107,7 @@ mod tests { mod page_bids_no_match_tests { use super::super::*; use crate::auction::AuctionOrchestrator; - use crate::creative_opportunities::{ - CreativeOpportunitiesFile, CreativeOpportunityFormat, CreativeOpportunitySlot, - }; + use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; use fastly::http::Method; @@ -2805,24 +3121,22 @@ mod tests { Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } - fn file_with_article_slot() -> CreativeOpportunitiesFile { - CreativeOpportunitiesFile { - slots: vec![CreativeOpportunitySlot { - id: "atf".to_string(), - gam_unit_path: None, - div_id: None, - page_patterns: vec!["/20**".to_string()], - formats: vec![CreativeOpportunityFormat { - width: 300, - height: 250, - media_type: crate::auction::types::MediaType::Banner, - }], - floor_price: Some(0.50), - targeting: Default::default(), - providers: Default::default(), - compiled_patterns: Vec::new(), + fn article_slot() -> Vec { + vec![CreativeOpportunitySlot { + id: "atf".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: crate::auction::types::MediaType::Banner, }], - } + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + }] } fn make_page_bids_request(path: &str) -> Request { @@ -2839,10 +3153,9 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = CreativeOpportunitiesFile { slots: vec![] }; let req = make_page_bids_request("/2024/01/my-article/"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &[], req) .await .expect("should return ok response"); @@ -2855,7 +3168,7 @@ mod tests { .expect("slots should be array") .len(), 0, - "empty slots file should produce zero injected slots" + "empty slots should produce zero injected slots" ); assert_eq!( body["bids"] @@ -2863,7 +3176,7 @@ mod tests { .expect("bids should be object") .len(), 0, - "empty slots file should produce zero bids" + "empty slots should produce zero bids" ); } @@ -2875,11 +3188,11 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); + let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("user-agent", "Mozilla/5.0 (compatible; Googlebot/2.1)"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); @@ -2911,11 +3224,11 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); + let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("sec-purpose", "prefetch"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); @@ -2946,10 +3259,10 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); // slot matches /20** only + let slots = article_slot(); // slot matches /20** only let req = make_page_bids_request("/about"); // does not match - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 386f0d54..b221e0ea 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -416,6 +416,15 @@ pub struct DebugConfig { /// Never enable in production — visible in page source. #[serde(default)] pub auction_html_comment: bool, + + /// Include raw `adm` creative markup in `window.tsjs.bids` for GPT/GAM + /// debug rendering through the Prebid Universal Creative bridge. + /// + /// Use this to validate the server-side auction→GAM targeting→creative + /// rendering pipeline while PBS Cache is unavailable. Never enable in + /// production — injects raw HTML from SSPs. + #[serde(default)] + pub inject_adm_for_testing: bool, } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -522,14 +531,29 @@ impl Settings { /// # Errors /// /// Returns a configuration error if any cached runtime artifact cannot be prepared. - pub fn prepare_runtime(&self) -> Result<(), Report> { + pub fn prepare_runtime(&mut self) -> Result<(), Report> { for handler in &self.handlers { handler.prepare_runtime()?; } + if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + } + Ok(()) } + /// Returns compiled creative opportunity slots, or empty slice if feature is disabled. + #[must_use] + pub fn creative_opportunity_slots( + &self, + ) -> &[crate::creative_opportunities::CreativeOpportunitySlot] { + self.creative_opportunities + .as_ref() + .map(|co| co.slot.as_slice()) + .unwrap_or(&[]) + } + /// Resolve the first handler whose regex matches the request path. /// /// # Errors diff --git a/creative-opportunities.toml b/creative-opportunities.toml deleted file mode 100644 index da1ed23e..00000000 --- a/creative-opportunities.toml +++ /dev/null @@ -1,47 +0,0 @@ -# Slot templates for server-side ad auction. -# Empty file = feature disabled (no auction fired, no globals injected). - -[[slot]] -id = "atf_sidebar_ad" -gam_unit_path = "/a/b/news" -div_id = "ad-atf_sidebar-0-_r_2_" -page_patterns = ["/20**", "/news/**"] -formats = [{ width = 300, height = 250 }] -floor_price = 0.50 - -[slot.targeting] -pos = "atf" -zone = "atfSidebar" - -[slot.providers.aps] -slot_id = "aps-slot-atf-sidebar" - -[[slot]] -id = "homepage_header_ad" -gam_unit_path = "/a/b/homepage" -div_id = "ad-header-0-_R_jpalubtak5lb_" -page_patterns = ["/"] -formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] -floor_price = 0.50 - -[slot.targeting] -pos = "atf" -zone = "header" - -[slot.providers.aps] -slot_id = "aps-slot-homepage-header" - -[[slot]] -id = "homepage_footer_ad" -gam_unit_path = "/a/b/homepage" -div_id = "ad-fixed_bottom-0-_R_klubtak5lb_" -page_patterns = ["/"] -formats = [{ width = 728, height = 90 }] -floor_price = 0.50 - -[slot.targeting] -pos = "btf" -zone = "fixedBottom" - -[slot.providers.aps] -slot_id = "aps-slot-homepage-footer" diff --git a/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md new file mode 100644 index 00000000..83866e3b --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md @@ -0,0 +1,630 @@ +# PR #680 Reviewer Findings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Address the two reviewer-required findings from PR #680 plus low-effort cleanups: consolidate slot config into `trusted-server.toml`, consolidate `window.__ts*` globals under `window.tsjs`, and fix the TypeScript `formats` type cast and `ts_initial` hardcoded string. + +**Architecture:** Slot templates move from the standalone `creative-opportunities.toml` (embedded via `include_str!`) into the `[creative_opportunities]` section of `trusted-server.toml`, using the existing `vec_from_seq_or_map` deserializer pattern already used for `BID_PARAM_ZONE_OVERRIDES`. The window globals rename is a coordinated change across `gpt_bootstrap.js`, `index.ts`, and `publisher.rs` — all three must change together since they share a runtime contract. + +**Tech Stack:** Rust (serde, toml), TypeScript, vanilla JS, `cargo test --workspace`, `npx vitest run` + +--- + +## Context for all tasks + +- **Branch:** create `fix/pr680-review-findings` off `server-side-ad-templates-impl` before starting +- **Current codebase:** `crates/trusted-server-core/`, `crates/trusted-server-adapter-fastly/`, `crates/js/lib/` +- **CI gates:** `cargo fmt`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace`, `npx vitest run`, `npm run format` +- **Error handling:** use `error-stack` (`Report`), not anyhow. Use `derive_more::Display`, not thiserror. +- **No `unwrap()` in production code** — use `expect("should ...")`. +- **Do not** add `println!` / `eprintln!` — use `log::` macros. + +--- + +## Task 1: Consolidate slot config into `trusted-server.toml` + +**What:** Delete `creative-opportunities.toml`. Move `[[slot]]` arrays into `trusted-server.toml` as `[[creative_opportunities.slot]]`. Wire the `vec_from_seq_or_map` deserializer so env var JSON blobs also work. Remove the `SLOTS_FILE` static and `include_str!` from `main.rs`. Update `build.rs` to validate slot IDs from settings instead of a separate file. + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` +- Modify: `crates/trusted-server-core/build.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` (function signatures) +- Modify: `trusted-server.toml` +- Delete: `creative-opportunities.toml` + +**Steps:** + +- [ ] **Step 1: Create the branch** + +```bash +git checkout -b fix/pr680-review-findings +``` + +- [ ] **Step 2: Add `Serialize` and `slot` field to structs** + +In `crates/trusted-server-core/src/creative_opportunities.rs`: + +1. Add `Serialize` to `CreativeOpportunitySlot` derive — it already has `#[serde(skip, default)]` on `compiled_patterns` so that field won't serialize. + +```rust +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunitySlot { ... } +``` + +Also add `Serialize` to `CreativeOpportunityFormat`, `SlotProviders`, `ApsSlotParams` (any struct used inside `CreativeOpportunitySlot`). + +2. Add a `slot` field to `CreativeOpportunitiesConfig`: + +```rust +use crate::settings::vec_from_seq_or_map; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default = "PriceGranularity::dense")] + pub price_granularity: PriceGranularity, + /// Slot templates. Empty = feature disabled. + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub slot: Vec, +} +``` + +Note: the field is named `slot` (not `slots`) to match the TOML key `[[creative_opportunities.slot]]`. + +- [ ] **Step 3: Delete `CreativeOpportunitiesFile`** + +Remove the `CreativeOpportunitiesFile` struct and its `impl` from `creative_opportunities.rs`. The `compile` logic moves to a free function or into `CreativeOpportunitiesConfig`: + +```rust +impl CreativeOpportunitiesConfig { + /// Pre-compile glob patterns for all slots. Call once after deserialization. + pub fn compile_slots(&mut self) { + for slot in &mut self.slot { + slot.compile_patterns(); + } + } +} +``` + +- [ ] **Step 4: Wire slot compilation into `Settings::prepare_runtime`** + +Glob pattern pre-compilation must happen once at startup, not per-request. `Settings::prepare_runtime` is already called after deserialization in both `from_toml_and_env` (build time) and `get_settings()` (runtime). Add slot compilation there: + +```rust +// In settings.rs, inside Settings::prepare_runtime +pub fn prepare_runtime(&mut self) -> Result<(), Report> { + for handler in &self.handlers { + handler.prepare_runtime()?; + } + // Pre-compile slot glob patterns for hot-path matching. + if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + } + Ok(()) +} +``` + +Note: `prepare_runtime` must take `&mut self` for this change. Check current signature — if it takes `&self`, change it to `&mut self` and update call sites. + +Also add a helper method for call sites that need the slot slice: + +```rust +impl Settings { + /// Returns compiled creative opportunity slots, or empty slice if disabled. + pub fn creative_opportunity_slots(&self) -> &[CreativeOpportunitySlot] { + self.creative_opportunities + .as_ref() + .map(|co| co.slot.as_slice()) + .unwrap_or(&[]) + } +} +``` + +- [ ] **Step 5: Update `build.rs` stub and slot validation** + +First update the `creative_opportunities` stub in `build.rs` to add the `slot` field — without this the settings parse will fail at build time when `trusted-server.toml` contains `[[creative_opportunities.slot]]` entries: + +```rust +mod creative_opportunities { + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Deserialize, Serialize)] + pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default = "default_price_granularity")] + pub price_granularity: String, + // Use serde_json::Value to avoid pulling in full slot type in build context. + #[serde(default)] + pub slot: Vec, + } + + fn default_price_granularity() -> String { + "dense".to_string() + } +} +``` + +Then replace the separate-file validation block with reading slots from `Settings`: + +```rust +// After settings are parsed, validate slot IDs +let slot_id_re = regex::Regex::new(r"^[A-Za-z0-9_\-]+$").expect("should compile regex"); +if let Some(co) = &settings.creative_opportunities { + for slot in &co.slot { + if let Err(e) = trusted_server_core::creative_opportunities::validate_slot_id(&slot.id) { + panic!("trusted-server.toml [creative_opportunities.slot]: {e}"); + } + } + if !co.slot.is_empty() { + println!( + "cargo:warning=creative_opportunities: {} slot(s) validated", + co.slot.len() + ); + } +} +``` + +Remove: `CREATIVE_OPPORTUNITIES_PATH` const, the `co_path.exists()` block, and the `println!("cargo:rerun-if-changed={}", CREATIVE_OPPORTUNITIES_PATH)` line. + +Note: `build.rs` already pulls in `src/creative_opportunities.rs` as a module — make sure the module stub includes the new `Serialize` derive (it may need the serde `Serialize` import). + +- [ ] **Step 6: Update `main.rs` — remove `SLOTS_FILE` static** + +Remove: + +```rust +const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); +static SLOTS_FILE: std::sync::LazyLock<...> = ...; +``` + +Replace `slots_file` parameter threading with deriving slots from `settings`: + +Where `slots_file` was passed as `&*SLOTS_FILE`, pass `settings.creative_opportunity_slots()` instead. This requires `settings` to be available at that call site (it is — `settings` is already in scope). + +Update function signatures in `main.rs` that reference `CreativeOpportunitiesFile` to accept `&[CreativeOpportunitySlot]` instead. + +- [ ] **Step 7: Update `publisher.rs` function signatures** + +Functions that take `&crate::creative_opportunities::CreativeOpportunitiesFile` change to `&[crate::creative_opportunities::CreativeOpportunitySlot]`: + +```rust +// Before +pub(crate) fn handle_page_bids( + ... + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + ... +) + +// After +pub(crate) fn handle_page_bids( + ... + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + ... +) +``` + +Inside the function body, replace `slots_file.slots` with `slots`. + +Update all call sites and test helpers in `publisher.rs` that construct `CreativeOpportunitiesFile { slots: vec![...] }` to pass `&[slot]` directly. + +- [ ] **Step 8: Update `trusted-server.toml`** + +Move the slots from `creative-opportunities.toml` into `trusted-server.toml` under `[creative_opportunities]`. Use `[[creative_opportunities.slot]]` syntax. Use only example/fictional values per project convention (example.com domains, fictional IDs): + +```toml +[creative_opportunities] +gam_network_id = "88059007" +auction_timeout_ms = 1500 +price_granularity = "dense" + +[[creative_opportunities.slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/a/b/news" +div_id = "div-ad-atf-sidebar" +page_patterns = ["/news/**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" +``` + +- [ ] **Step 9: Delete `creative-opportunities.toml`** + +```bash +git rm creative-opportunities.toml +``` + +- [ ] **Step 10: Run tests** + +```bash +cargo test --workspace +``` + +Expected: all tests pass. Fix any compile errors from the signature changes. + +- [ ] **Step 11: Run clippy and fmt** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 12: Commit** + +```bash +git add -p +git commit -m "Move slot templates from creative-opportunities.toml into trusted-server.toml" +``` + +--- + +## Task 2: Consolidate `window.__ts*` globals under `window.tsjs` + +**What:** All `window.__ts*` globals become properties on a single `window._ts` namespace object. Changes must be coordinated across three files: `gpt_bootstrap.js`, `index.ts`, and `publisher.rs`. Tests in `index.test.ts` must be updated too. + +**Rename table:** + +| Old global | New property | Notes | +| ----------------------------- | ------------------------------ | ---------------------------- | +| `window.__ts_ad_slots` | `window.tsjs.adSlots` | Array, set at head-open | +| `window.__ts_bids` | `window.tsjs.bids` | Object, set before `` | +| `window.__tsAdInit` | `window.tsjs.adInit` | Function | +| `window.__tsPrevGptSlots` | `window.tsjs.prevGptSlots` | Array | +| `window.__tsServicesEnabled` | `window.tsjs.servicesEnabled` | Boolean | +| `window.__tsDivToSlotId` | `window.tsjs.divToSlotId` | Object | +| `window.__tsSpaHookInstalled` | `window.tsjs.spaHookInstalled` | Boolean | + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/js/lib/src/integrations/gpt/index.test.ts` +- Modify: `crates/js/lib/test/integrations/gpt/index.test.ts` (if exists) + +**Steps:** + +- [ ] **Step 1: Update `publisher.rs` injected scripts** + +`build_ad_slots_script` generates the `", escaped) + +// After — initialise _ts if absent, then set adSlots +format!("", escaped) +``` + +`build_bids_script` generates the script injected before ``. Change: + +```rust +// Before +format!( + "", + escaped +) + +// After +format!( + "", + escaped +) +``` + +Note: `{{}}` is the Rust format-string escape for a literal `{}`. + +Update any test assertions in `publisher.rs` that check for the old global names. + +- [ ] **Step 2: Update `gpt_bootstrap.js`** + +Replace all `window.__ts*` references. The bootstrap IIFE runs before the TS bundle, so it must initialise `window._ts` if absent: + +```js +;(function () { + if (typeof window === 'undefined') return + // Initialise namespace; adInit guard prevents double-install. + var ts = (window._ts = window._ts || {}) + if (ts.adInit) return + + ts.adInit = function () { + var slots = ts.adSlots || [] + var bids = ts.bids || {} + var divToSlotId = {} + googletag.cmd.push(function () { + var newSlots = [] + slots.forEach(function (slot) { + var s = googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + slot.div_id + ) + if (!s) return + s.addService(googletag.pubads()) + Object.entries(slot.targeting || {}).forEach(function (e) { + s.setTargeting(e[0], e[1]) + }) + var b = bids[slot.id] || {} + ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]) + }) + s.setTargeting('ts_initial', '1') + divToSlotId[slot.div_id] = slot.id + newSlots.push(s) + }) + ts.prevGptSlots = newSlots + ts.divToSlotId = divToSlotId + if (!ts.servicesEnabled) { + googletag.pubads().enableSingleRequest() + googletag.enableServices() + ts.servicesEnabled = true + googletag.pubads().addEventListener('slotRenderEnded', function (ev) { + var divId = ev.slot.getSlotElementId() + var slotId = (ts.divToSlotId || {})[divId] + if (!slotId) return + var b = (ts.bids || {})[slotId] || {} + var ourBidWon = + !ev.isEmpty && + (b.hb_adid + ? ev.slot.getTargeting('hb_adid')[0] === b.hb_adid + : !!b.hb_bidder) + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl) + if (b.burl) navigator.sendBeacon(b.burl) + } + }) + } + if (newSlots.length > 0) { + googletag.pubads().refresh(newSlots) + } + }) + } +})() +``` + +- [ ] **Step 3: Update `index.ts` — rename `TsWindow` type** + +Replace the `TsWindow` interface: + +```typescript +type TsNamespace = { + adSlots?: TsAdSlot[] + bids?: Record + adInit?: () => void + prevGptSlots?: GoogleTagSlot[] + servicesEnabled?: boolean + divToSlotId?: Record + spaHookInstalled?: boolean +} + +type TsWindow = Window & { + _ts?: TsNamespace +} +``` + +- [ ] **Step 4: Update `installTsAdInit` in `index.ts`** + +Update all properties to live under `window.tsjs`. Use `window.tsjs` directly: + +```typescript +export function installTsAdInit(): void { + const w = window as TsWindow + const ts = (w._ts = w._ts ?? {}) + ts.adInit = function () { + const slots = ts.adSlots ?? [] + const bids = ts.bids ?? {} + const g = (window as GptWindow).googletag + if (!g) return + + g.cmd?.push(() => { + if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + g.destroySlots?.(ts.prevGptSlots) + ts.prevGptSlots = [] + } + const newSlots: GoogleTagSlot[] = [] + const divToSlotId: Record = {} + + slots.forEach((slot) => { + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats as Array, + slot.div_id + ) + if (!gptSlot) return + gptSlot.addService(g.pubads!()) + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => + gptSlot.setTargeting(k, v) + ) + const bid = bids[slot.id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + gptSlot.setTargeting('ts_initial', '1') + divToSlotId[slot.div_id] = slot.id + newSlots.push(gptSlot) + }) + + ts.prevGptSlots = newSlots + ts.divToSlotId = divToSlotId + + if (!ts.servicesEnabled) { + g.pubads!().enableSingleRequest() + g.enableServices?.() + ts.servicesEnabled = true + g.pubads!().addEventListener?.( + 'slotRenderEnded', + (event: SlotRenderEndedEvent) => { + const divId: string = event.slot?.getSlotElementId?.() ?? '' + const slotId = (ts.divToSlotId ?? {})[divId] + if (!slotId) return + const bid = (ts.bids ?? {})[slotId] ?? {} + const ourBidWon = + !event.isEmpty && + (bid.hb_adid + ? event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + : !!bid.hb_bidder) + if (ourBidWon) { + if (bid.nurl) navigator.sendBeacon(bid.nurl) + if (bid.burl) navigator.sendBeacon(bid.burl) + } + } + ) + } + if (newSlots.length > 0) { + g.pubads!().refresh(newSlots) + } + }) + } +} +``` + +- [ ] **Step 5: Update `installSpaHook` in `index.ts`** + +Replace `__tsSpaHookInstalled` and `__ts_ad_slots`/`__ts_bids` reads: + +```typescript +export function installSpaHook(): void { + const win = window as TsWindow + const ts = (win._ts = win._ts ?? {}) + if (ts.spaHookInstalled) return + ts.spaHookInstalled = true + // ... rest of SPA hook logic uses ts.adSlots, ts.bids, ts.adInit +} +``` + +- [ ] **Step 6: Update tests in `index.test.ts`** + +Find all test assertions that reference `window.__ts_ad_slots`, `window.__ts_bids`, `window.__tsAdInit`, etc. and update to `window.tsjs.adSlots`, `window.tsjs.bids`, `window.tsjs.adInit` etc. + +Run tests first to see what fails: + +```bash +cd crates/js/lib && npx vitest run +``` + +Fix each failing assertion. + +- [ ] **Step 7: Run JS tests and format** + +```bash +cd crates/js/lib && npx vitest run +cd crates/js/lib && npm run format +``` + +Expected: all tests pass, no format errors. + +- [ ] **Step 8: Run Rust tests** + +```bash +cargo test --workspace +``` + +Update any test assertions in `publisher.rs` that check for old global names (e.g. `script.contains("window.__ts_ad_slots")`). + +- [ ] **Step 9: Run clippy and fmt** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 10: Commit** + +```bash +git commit -m "Namespace window globals under window._ts" +``` + +--- + +## Task 3: Fix `formats` type and extract `ts_initial` constant + +**What:** Two small TypeScript/JS cleanups. `TsAdSlot.formats` should be typed as `Array<[number, number]>` (tuple, not array-of-array) to match GPT's actual input. The string `'ts_initial'` is hardcoded in both `gpt_bootstrap.js` and `index.ts` — extract as a named constant in `index.ts` (no JS equivalent needed since the bootstrap is vanilla JS). + +**Files:** + +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` (comment only — JS can't share TS constants) + +**Steps:** + +- [ ] **Step 1: Fix `TsAdSlot.formats` type** + +In `index.ts`, change: + +```typescript +// Before +interface TsAdSlot { + ... + formats: Array; +} + +// After +interface TsAdSlot { + ... + formats: Array<[number, number]>; +} +``` + +Update the cast at the GPT `defineSlot` call site — `[number, number]` satisfies `number | number[]` so the cast can be removed or simplified: + +```typescript +// Before +slot.formats as Array + +// After — [number, number][] already satisfies Array +slot.formats +``` + +- [ ] **Step 2: Extract `ts_initial` constant in `index.ts`** + +Near the top of `index.ts`, add: + +```typescript +const TS_INITIAL_TARGETING_KEY = 'ts_initial' +``` + +Replace both occurrences of `'ts_initial'` in `installTsAdInit` with `TS_INITIAL_TARGETING_KEY`. + +Add a comment in `gpt_bootstrap.js` where `'ts_initial'` appears: + +```js +// Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts +s.setTargeting('ts_initial', '1') +``` + +- [ ] **Step 3: Run JS tests and format** + +```bash +cd crates/js/lib && npx vitest run +cd crates/js/lib && npm run format +``` + +- [ ] **Step 4: Commit** + +```bash +git commit -m "Fix TsAdSlot formats type and extract ts_initial constant" +``` + +--- + +## Final verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace` +- [ ] `cd crates/js/lib && npx vitest run` +- [ ] `cd crates/js/lib && npm run format` +- [ ] `cd docs && npm run format` diff --git a/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md new file mode 100644 index 00000000..7a3f3420 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md @@ -0,0 +1,760 @@ +# Prebid Creative Rendering Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix `hb_adid` to carry the PBS Cache UUID (not the OpenRTB bid ID) so the Prebid Universal Creative in GAM can fetch and render the correct creative markup. + +**Architecture:** Three-file change: add `cache_id`/`cache_host`/`cache_path` fields to the shared `Bid` struct in `types.rs`, extract these from `ext.prebid.cache.bids` in `prebid.rs`'s `parse_bid`, then emit them as `hb_adid`/`hb_cache_host`/`hb_cache_path` in `publisher.rs`'s `build_bid_map`. `AuctionBid` in `prebid.rs` is a type alias for `Bid` (`use ... Bid as AuctionBid`), so only one struct needs the new fields. + +**Tech Stack:** Rust 2024, `serde`, `url` crate (already in workspace deps at v2.5.8), `cargo test --workspace` + +--- + +## Context for all tasks + +- **Branch:** `fix/server-side-ad-template-entrypoint` (already checked out) +- **Spec:** `docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md` +- **Error handling:** `error-stack` (`Report`), not anyhow. Use `expect("should ...")` not `unwrap()`. +- **No `println!`/`eprintln!`** — use `log::` macros. +- **All public items must have doc comments.** +- CI gates: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace` + +--- + +## Task 1: Add cache fields to `Bid` struct and fix all construction sites + +**What:** Add three new `Option` fields to `Bid`. Since Rust struct literals are exhaustive, every place that constructs a `Bid { ... }` in the codebase will fail to compile until the new fields are added. Fix all of them with `None` defaults (except the APS provider which constructs a real `Bid` — also `None` since APS doesn't use PBS Cache). + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs:200` (after `ad_id` field) +- Modify (test helpers/literals — add `None` fields): + - `crates/trusted-server-core/src/auction/types.rs:314` (`make_bid` helper) + - `crates/trusted-server-core/src/auction/types.rs:445` (inline `Bid` literal) + - `crates/trusted-server-core/src/publisher.rs:2616` (`make_bid` helper) + - `crates/trusted-server-core/src/publisher.rs:2714` (inline `Bid` literal) + - `crates/trusted-server-core/src/auction/orchestrator.rs:1121,1138,1278,1325,1358` (test `Bid` literals) + - `crates/trusted-server-core/src/integrations/aps.rs:442` (production `Bid` construction) + +**Steps:** + +- [ ] **Step 1: Add three fields to `Bid` struct in `types.rs`** + + In `crates/trusted-server-core/src/auction/types.rs`, after line 200 (`pub ad_id: Option,`), add: + + ```rust + /// Prebid Cache UUID for this bid. + /// + /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. + /// Used as `hb_adid` targeting value in `window._ts.bids`. `None` for + /// non-PBS providers (e.g., APS) and PBS bids without Prebid Cache enabled. + pub cache_id: Option, + /// Prebid Cache host (e.g., `"openads.adsrvr.org"`). + /// + /// Populated from the host of `ext.prebid.cache.bids.url`. Used as + /// `hb_cache_host` targeting value. `None` when cache is absent. + pub cache_host: Option, + /// Prebid Cache path (e.g., `"/cache"`). + /// + /// Populated from the path of `ext.prebid.cache.bids.url`. Used as + /// `hb_cache_path` targeting value. `None` when cache is absent. + pub cache_path: Option, + ``` + +- [ ] **Step 2: Verify compile fails as expected** + + ```bash + cargo check --package trusted-server-core 2>&1 | grep "missing field" + ``` + + Expected: multiple errors about missing `cache_id`, `cache_host`, `cache_path` in `Bid` struct literals. This confirms every construction site will be found. + +- [ ] **Step 3: Fix `make_bid` helper in `types.rs` (line ~314)** + + Add three `None` fields to the `Bid {}` literal inside the `make_bid` test helper: + + ```rust + fn make_bid(bidder: &str) -> Bid { + Bid { + slot_id: "slot-1".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + ``` + +- [ ] **Step 4: Fix inline `Bid` literal in `types.rs` (line ~445)** + + Find the `Bid {` literal around line 445 in the test section of `types.rs`. Add: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 5: Fix `make_bid` helper in `publisher.rs` (line ~2616)** + + In the `make_bid` test helper function in `publisher.rs`, add to the `Bid {}` literal: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 6: Fix inline `Bid` literal in `publisher.rs` (line ~2714)** + + Find the `Bid {` literal around line 2714 in `publisher.rs` tests. Add: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 7: Fix five `Bid` literals in `orchestrator.rs` (lines ~1121,1138,1278,1325,1358)** + + Add to each of the five `Bid {}` literals in the test section of `orchestrator.rs`: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + +- [ ] **Step 8: Fix APS production `Bid` construction in `aps.rs` (line ~442)** + + In `aps.rs`, inside `parse_aps_response` (or wherever the `Ok(Bid { ... })` is around line 442), add: + + ```rust + cache_id: None, + cache_host: None, + cache_path: None, + ``` + + APS does not use PBS Cache — these fields are intentionally `None` for APS bids. + +- [ ] **Step 9: Verify compile succeeds** + + ```bash + cargo check --package trusted-server-core 2>&1 | grep -E "^error" + ``` + + Expected: no output (clean compile). + +- [ ] **Step 10: Run tests to confirm nothing regressed** + + ```bash + cargo test --workspace 2>&1 | tail -5 + ``` + + Expected: all tests pass. + +- [ ] **Step 11: Run clippy and fmt** + + ```bash + cargo fmt --all + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5 + ``` + + Expected: clean. + +- [ ] **Step 12: Commit** + + ```bash + git add crates/trusted-server-core/src/auction/types.rs \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/auction/orchestrator.rs \ + crates/trusted-server-core/src/integrations/aps.rs + git commit -m "Add cache_id, cache_host, cache_path fields to Bid struct" + ``` + +--- + +## Task 2: Extract PBS Cache fields in `prebid.rs` `parse_bid` + tests + +**What:** After extracting `ad_id` in `parse_bid`, extract `ext.prebid.cache.bids.cacheId` as `cache_id` and split `ext.prebid.cache.bids.url` into `cache_host` + `cache_path`. Populate all three new fields on the returned `AuctionBid`. Add TDD tests first. + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs:1362–1391` (extraction + struct literal) +- Test: `crates/trusted-server-core/src/integrations/prebid.rs` (test module near bottom) + +**Steps:** + +- [ ] **Step 1: Write the failing tests** + + Find the `#[cfg(test)]` module in `prebid.rs`. Add these tests (they will fail because extraction doesn't exist yet): + + ```rust + #[test] + fn parse_bid_extracts_cache_id_from_ext_prebid_cache_bids() { + // Real PBS response shape from auction_response.json + let bid_json = serde_json::json!({ + "id": "bid-id-123", + "impid": "atf_sidebar_ad", + "price": 1.50, + "adm": "
ad
", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "thetradedesk") + .expect("should parse bid"); + assert_eq!( + bid.cache_id.as_deref(), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should extract cacheId as cache_id" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("openads.adsrvr.org"), + "should extract host from cache URL" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should extract path from cache URL" + ); + } + + #[test] + fn parse_bid_sets_cache_fields_to_none_when_no_cache_entry() { + let bid_json = serde_json::json!({ + "id": "bid-id-456", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250 + // no ext.prebid.cache + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert!(bid.cache_id.is_none(), "should be None when cache absent"); + assert!(bid.cache_host.is_none(), "should be None when cache absent"); + assert!(bid.cache_path.is_none(), "should be None when cache absent"); + } + + #[test] + fn parse_bid_handles_malformed_cache_url_gracefully() { + let bid_json = serde_json::json!({ + "id": "bid-id-789", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "not-a-valid-url", + "cacheId": "some-uuid" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid without panicking"); + assert_eq!( + bid.cache_id.as_deref(), + Some("some-uuid"), + "should still extract cacheId even if URL is malformed" + ); + assert!(bid.cache_host.is_none(), "should be None when URL parse fails"); + assert!(bid.cache_path.is_none(), "should be None when URL parse fails"); + } + + #[test] + fn parse_bid_preserves_ad_id_alongside_cache_id() { + let bid_json = serde_json::json!({ + "id": "bid-impression-id", + "impid": "atf_sidebar_ad", + "adid": "bidder-ad-id-abc", + "price": 1.0, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://cache.example.com/cache", + "cacheId": "cache-uuid-xyz" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert_eq!( + bid.ad_id.as_deref(), + Some("bidder-ad-id-abc"), + "should keep ad_id from adid field" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid-xyz"), + "should extract cache UUID separately" + ); + } + ``` + + Note: `base_config()` and `PrebidAuctionProvider::new()` are the standard test construction pattern used throughout the existing `prebid.rs` test module. `parse_bid` is a private method but is accessible from the `#[cfg(test)]` module in the same file. + +- [ ] **Step 2: Run tests to verify they fail** + + ```bash + cargo test --package trusted-server-core parse_bid_extracts_cache_id 2>&1 | tail -15 + ``` + + Expected: compile error (`no field 'cache_id' on type 'Bid'`) or test failure. Either confirms the extraction code is missing. + +- [ ] **Step 3: Add cache extraction to `parse_bid` in `prebid.rs`** + + In `parse_bid` (around line 1362), after the `ad_id` extraction block and before the `Ok(AuctionBid { ... })`, add: + + ```rust + // Extract PBS Cache coordinates from ext.prebid.cache.bids. + // The Prebid Universal Creative uses cacheId as hb_adid and the host/path + // to construct the fetch URL: https://?uuid= + let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + + let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + + let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + url::Url::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {e}")) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { + None + } else { + Some(path) + }; + (host, path) + }) + .unwrap_or((None, None)); + + if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{slot_id}'" + ); + } + ``` + + Then add the three fields to the `Ok(AuctionBid { ... })` struct literal (around line 1377): + + ```rust + Ok(AuctionBid { + slot_id, + price: Some(price), + currency: DEFAULT_CURRENCY.to_string(), + creative, + adomain, + bidder: seat.to_string(), + width, + height, + nurl, + burl, + ad_id, + cache_id, + cache_host, + cache_path, + metadata: std::collections::HashMap::new(), + }) + ``` + +- [ ] **Step 4: Run tests to verify they pass** + + ```bash + cargo test --package trusted-server-core parse_bid 2>&1 | tail -20 + ``` + + Expected: all 4 new tests pass. + +- [ ] **Step 5: Run full test suite** + + ```bash + cargo test --workspace 2>&1 | tail -5 + ``` + + Expected: all tests pass. + +- [ ] **Step 6: Run clippy and fmt** + + ```bash + cargo fmt --all + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5 + ``` + + Expected: clean. If clippy warns about the `log::debug!` return value being unused inside `map_err`, suppress with `let _ = ...` or restructure. + +- [ ] **Step 7: Commit** + + ```bash + git add crates/trusted-server-core/src/integrations/prebid.rs + git commit -m "Extract PBS Cache UUID and endpoint from bid ext into Bid fields" + ``` + +--- + +## Task 3: Emit cache fields in `build_bid_map` + update tests + +**What:** Change `build_bid_map` to use `bid.cache_id` for `hb_adid` (falling back to `bid.ad_id` for APS/other providers), and emit `hb_cache_host`/`hb_cache_path` when present. Update the existing `bid_map_includes_nurl_and_burl` test (which currently passes `"abc123"` as `ad_id` and asserts `hb_adid = "abc123"`) to use a cache-based bid. Add new tests covering cache fields and fallback path. + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs:1311–1342` (`build_bid_map`) +- Modify: `crates/trusted-server-core/src/publisher.rs:2608–2630` (`make_bid` helper — add cache params) +- Modify: `crates/trusted-server-core/src/publisher.rs:2666–2707` (existing `bid_map_includes_nurl_and_burl` test) +- Test: `crates/trusted-server-core/src/publisher.rs` (new tests in the existing test module) + +**Steps:** + +- [ ] **Step 1: Write new failing tests for cache field emission** + + Add these tests to the `#[cfg(test)]` module in `publisher.rs`, near the existing `bid_map_includes_nurl_and_burl` test: + + ```rust + #[test] + fn bid_map_uses_cache_id_for_hb_adid_when_present() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), + cache_host: Some("openads.adsrvr.org".to_string()), + cache_path: Some("/cache".to_string()), + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let obj = map + .get("atf_sidebar_ad") + .expect("should have entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should use cache_id for hb_adid, not ad_id" + ); + assert_eq!( + obj.get("hb_cache_host").and_then(|v| v.as_str()), + Some("openads.adsrvr.org"), + "should emit hb_cache_host" + ); + assert_eq!( + obj.get("hb_cache_path").and_then(|v| v.as_str()), + Some("/cache"), + "should emit hb_cache_path" + ); + } + + #[test] + fn bid_map_falls_back_to_ad_id_when_cache_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "aps-amazon".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("aps-bid-token".to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let obj = map + .get("atf_sidebar_ad") + .expect("should have entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("aps-bid-token"), + "should fall back to ad_id when cache_id absent" + ); + assert!( + obj.get("hb_cache_host").is_none(), + "should not emit hb_cache_host when absent" + ); + assert!( + obj.get("hb_cache_path").is_none(), + "should not emit hb_cache_path when absent" + ); + } + + #[test] + fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let obj = map + .get("atf_sidebar_ad") + .expect("should have entry") + .as_object() + .expect("should be object"); + + assert!( + obj.get("hb_adid").is_none(), + "should omit hb_adid when no cache_id and no ad_id" + ); + } + ``` + +- [ ] **Step 2: Run tests to verify they fail** + + ```bash + cargo test --package trusted-server-core bid_map_uses_cache_id 2>&1 | tail -15 + ``` + + Expected: test fails — `hb_adid` returns `"bid-impression-id"` (the wrong value) instead of the cache UUID, and `hb_cache_host`/`hb_cache_path` are not emitted. + +- [ ] **Step 3: Update `build_bid_map` in `publisher.rs`** + + Replace the current `hb_adid` emission block (lines ~1326–1331) and the `nurl`/`burl` block with: + + ```rust + // hb_adid: PBS Cache UUID when present (Prebid Universal Creative uses this + // as the cache lookup key). Falls back to ad_id for APS and other non-PBS + // providers. Note: ad_id (OpenRTB bid ID) is NOT the same as the cache UUID. + let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); + if let Some(id) = hb_adid { + obj.insert( + "hb_adid".to_string(), + serde_json::Value::String(id.to_string()), + ); + } + + // Cache endpoint coordinates — only present for PBS bids with Prebid Cache. + // The Prebid Universal Creative constructs: + // https://?uuid= + if let Some(ref host) = bid.cache_host { + obj.insert( + "hb_cache_host".to_string(), + serde_json::Value::String(host.clone()), + ); + } + if let Some(ref path) = bid.cache_path { + obj.insert( + "hb_cache_path".to_string(), + serde_json::Value::String(path.clone()), + ); + } + + if let Some(ref nurl) = bid.nurl { + obj.insert("nurl".to_string(), serde_json::Value::String(nurl.clone())); + } + if let Some(ref burl) = bid.burl { + obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); + } + ``` + +- [ ] **Step 4: Update the existing `bid_map_includes_nurl_and_burl` test** + + The existing test at line ~2666 constructs a bid via `make_bid("atf_sidebar_ad", 1.50, "kargo", "abc123", ...)` and asserts `hb_adid = "abc123"`. Update `make_bid` to accept optional `cache_id`, `cache_host`, `cache_path`, OR create a separate variant. The simplest fix: update the assertion in the existing test to reflect the new priority logic. + + The test currently passes `ad_id = "abc123"` and `cache_id = None`. After the fix, `hb_adid` should still be `"abc123"` (fallback path). So the existing assertion is correct — just verify it still passes. No change needed to that test body. Just update `make_bid` to set the new fields to `None`: + + ```rust + fn make_bid( + slot_id: &str, + price: f64, + bidder: &str, + ad_id: &str, + nurl: &str, + burl: &str, + ) -> Bid { + Bid { + slot_id: slot_id.to_string(), + price: Some(price), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: Some(nurl.to_string()), + burl: Some(burl.to_string()), + ad_id: Some(ad_id.to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + } + } + ``` + + Also update the assertion comment at line ~2694 from `"should include ad_id"` to `"should fall back to ad_id when no cache_id"`. + +- [ ] **Step 5: Run all new tests** + + ```bash + cargo test --package trusted-server-core bid_map 2>&1 | tail -20 + ``` + + Expected: all `bid_map_*` tests pass, including both new and existing. + +- [ ] **Step 6: Add round-trip serialization test for `Bid`** + + Add this test to the `#[cfg(test)]` module in `types.rs`: + + ```rust + #[test] + fn bid_with_cache_fields_round_trips_through_json() { + let bid = Bid { + slot_id: "atf".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-id".to_string()), + cache_id: Some("cache-uuid".to_string()), + cache_host: Some("cache.example.com".to_string()), + cache_path: Some("/pbc/v1/cache".to_string()), + metadata: HashMap::new(), + }; + let json = serde_json::to_string(&bid).expect("should serialize Bid"); + let restored: Bid = serde_json::from_str(&json).expect("should deserialize Bid"); + assert_eq!(restored.cache_id.as_deref(), Some("cache-uuid"), "should round-trip cache_id"); + assert_eq!(restored.cache_host.as_deref(), Some("cache.example.com"), "should round-trip cache_host"); + assert_eq!(restored.cache_path.as_deref(), Some("/pbc/v1/cache"), "should round-trip cache_path"); + } + ``` + + Run: + + ```bash + cargo test --package trusted-server-core bid_with_cache_fields_round_trips 2>&1 | tail -5 + ``` + + Expected: PASS. + +- [ ] **Step 7: Run full CI suite** + + ```bash + cargo test --workspace 2>&1 | tail -5 + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5 + ``` + + Expected: all pass, no warnings. + +- [ ] **Step 8: Commit** + + ```bash + git add crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/auction/types.rs + git commit -m "Emit hb_adid from PBS Cache UUID and add hb_cache_host/hb_cache_path to bid map" + ``` + +--- + +## Final verification + +- [ ] Run `cargo test --workspace` — all pass +- [ ] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean +- [ ] Run `cargo fmt --all -- --check` — clean +- [ ] In browser devtools after deploy: `window._ts.bids` shows `hb_cache_host`, `hb_cache_path`, and `hb_adid` matching the UUID in `ext.prebid.cache.bids.cacheId` from the raw PBS response + +--- + +## Rollout reminder (from spec §8) + +1. TS: this branch deployed +2. GAM: ad ops updates Prebid line item creatives to server-side cache-fetch variant (see spec §4.6) +3. PBS: Prebid Cache already enabled (confirmed from real response) +4. Verify in devtools diff --git a/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md new file mode 100644 index 00000000..a21ec4d2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md @@ -0,0 +1,345 @@ +# Prebid Creative Rendering Fix Design + +_Author · 2026-05-29_ + +--- + +## 1. Problem Statement + +The Trusted Server server-side auction returns winning bids from PBS, but ads never +render on the Prebid path because `hb_adid` carries the wrong value. + +The Prebid Universal Creative in GAM constructs the creative fetch URL as: + +``` +https://?uuid= +``` + +TS currently sets `hb_adid` from `bid.adid` or `bid.id` (the OpenRTB bid ID / +impression ID). PBS actually caches the creative markup and returns the cache UUID +in `ext.prebid.cache.bids.cacheId`. The Universal Creative needs the **cache UUID**, +not the bid ID. The cache host and path are also not forwarded today. + +**Effect:** GAM receives a wrong UUID, fetches nothing, and the slot renders empty. + +--- + +## 2. Root Cause — Two Extraction Gaps + +### Gap 1: Wrong `hb_adid` source + +`prebid.rs` extracts: + +```rust +let ad_id = bid_obj + .get("adid") + .or_else(|| bid_obj.get("id")) // ← falls back to impression ID + .and_then(|v| v.as_str()) + .map(String::from); +``` + +Real PBS response has (in `ext.prebid.cache.bids`): + +```json +{ + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" +} +``` + +`bid.id` = `"ad-header-0-_R_4uapbsnql8alb_"` — the impression ID, useless to the +creative renderer. + +### Gap 2: Cache host and path not forwarded + +`build_bid_map` in `publisher.rs` emits `hb_pb`, `hb_bidder`, `hb_adid`, `nurl`, +`burl`. It does not emit `hb_cache_host` or `hb_cache_path`. The Prebid Universal +Creative needs both to construct the fetch URL. + +--- + +## 3. Non-Goals + +- APS creative rendering — APS does not use PBS Cache. APS creative delivery is + Amazon-owned and not addressed here. +- APS win detection over-fire — separate known limitation, separate issue. +- Dual bootstrap sync risk — separate maintenance issue. +- Slim-Prebid bundle — out of scope for Phase 1. + +--- + +## 4. Design + +### 4.1 New Fields on `Bid` (types.rs) + +Add three fields to `Bid` to carry the PBS Cache coordinates extracted from the bid +response: + +```rust +/// Prebid Cache UUID for this bid. Populated from +/// `ext.prebid.cache.bids.cacheId` in the PBS response. +/// Used as `hb_adid` targeting value in `window.tsjs.bids`. +/// None for non-PBS providers (e.g., APS) and PBS bids without cache enabled. +pub cache_id: Option, + +/// Prebid Cache host (e.g., `"openads.adsrvr.org"`). Populated from +/// the host component of `ext.prebid.cache.bids.url`. +/// Used as `hb_cache_host` targeting value. +pub cache_host: Option, + +/// Prebid Cache path (e.g., `"/cache"`). Populated from +/// the path component of `ext.prebid.cache.bids.url`. +/// Used as `hb_cache_path` targeting value. +pub cache_path: Option, +``` + +### 4.2 Extraction in `prebid.rs` + +In `parse_bid_object`, after extracting `nurl`/`burl`, extract the cache fields from +`ext.prebid.cache.bids`: + +```rust +// Extract PBS Cache coordinates from ext.prebid.cache.bids +let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + +let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + +let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + url::Url::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {}", e)) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + // path() returns "/" for root — only use if non-trivial + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { None } else { Some(path) }; + (host, path) + }) + .unwrap_or((None, None)); + +// Guard: if we extracted a cache UUID but couldn't extract the host, +// the bid will have hb_adid set but no endpoint to fetch from — creative will fail. +if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{}'", + slot_id + ); +} +``` + +Note: `url` crate is already a workspace dependency. If not, parse host/path manually +by splitting on the first `/` after the scheme. + +The `ad_id` field (from `bid.adid` / `bid.id`) is **kept** — it maps to the OpenRTB +`adid` / `id` field that APS and other non-PBS providers may use. The cache fields are +**in addition**, not replacing `ad_id`. + +Populate all three fields on `AuctionBid`: + +```rust +Ok(AuctionBid { + ..., + ad_id, + cache_id, + cache_host, + cache_path, + ... +}) +``` + +### 4.3 `build_bid_map` in `publisher.rs` + +Priority for `hb_adid`: use `cache_id` when present (PBS path), fall back to `ad_id` +(APS / other providers, backward compat): + +```rust +// hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses +// this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to +// bid.ad_id for APS and other non-PBS providers. +let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); +if let Some(id) = hb_adid { + obj.insert("hb_adid".to_string(), serde_json::Value::String(id.to_string())); +} + +// Cache coordinates — only present for PBS bids with Prebid Cache enabled +if let Some(ref host) = bid.cache_host { + obj.insert("hb_cache_host".to_string(), serde_json::Value::String(host.clone())); +} +if let Some(ref path) = bid.cache_path { + obj.insert("hb_cache_path".to_string(), serde_json::Value::String(path.clone())); +} +``` + +### 4.4 What `window.tsjs.bids` looks like after the fix + +```json +{ + "atf_sidebar_ad": { + "hb_pb": "0.01", + "hb_bidder": "thetradedesk", + "hb_adid": "f47447a0-b759-4f2f-9887-af458b79b570", + "hb_cache_host": "openads.adsrvr.org", + "hb_cache_path": "/cache", + "nurl": "https://...", + "burl": "https://..." + } +} +``` + +### 4.5 Win detection — no change required + +`slotRenderEnded` checks: + +```js +event.slot.getTargeting('hb_adid')[0] === bid.hb_adid +``` + +`adInit()` calls `setTargeting('hb_adid', cacheId)` with the cache UUID. +`event.slot.getTargeting('hb_adid')[0]` returns that same cache UUID. +`bid.hb_adid` is now also the cache UUID. +Match holds. No change to the win detection logic. + +### 4.6 GAM line item creative requirement (publisher action — not TS code) + +This is a **hard dependency outside the TS codebase**. The publisher must configure +GAM line items with a server-side compatible Prebid creative. The standard +client-side Universal Creative calls `pbjs.renderAd()` which requires Prebid.js to be +loaded — it will not be at first render (slim-Prebid loads post-`window.load`). + +The server-side compatible creative uses the `hb_cache_*` macros to fetch the markup +directly from PBS Cache: + +```html + +``` + +Alternatively, publishers using the Prebid Universal Creative package can use: + +```html + + +``` + +> **This creative configuration is a publisher/ad ops action, not a TS code change.** +> Document it in the integration guide and verify during onboarding. + +> **Cache TTL:** PBS Cache entries expire per the `bid.exp` field (default 300–3600s; +> the real response has `"exp": 3600`). Creative fetch must complete within this window. +> BFCache page restores after long idle sessions may hit expired cache entries — the +> creative will silently fail to render in that case. This is acceptable for Phase 1; +> the probability is low for typical session lengths. + +--- + +## 5. APS — Out of Scope + +APS does not use PBS Cache. APS bids will have `cache_id = None`, `cache_host = None`, +`cache_path = None`. The existing `ad_id` fallback path remains for APS. APS creative +rendering depends on Amazon's own GAM creative tag — separate from the Prebid path. + +APS win detection over-fires on the `!!bid.hb_bidder` fallback remain a known +limitation tracked separately. + +--- + +## 6. Files Changed + +| File | Change | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/auction/types.rs` | Add `cache_id`, `cache_host`, `cache_path` to `Bid` struct | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Extract `ext.prebid.cache.bids.{cacheId,url}` in `parse_bid_object`; update `AuctionBid` → `Bid` conversion to carry the three new fields | +| `crates/trusted-server-core/src/publisher.rs` | `build_bid_map`: use `cache_id` for `hb_adid`, emit `hb_cache_host`/`hb_cache_path` | + +> **Implementer note — `AuctionBid` → `Bid` conversion:** `prebid.rs` constructs an +> intermediate `AuctionBid` type that is later converted to the shared `Bid` type from +> `types.rs`. The new `cache_id`, `cache_host`, `cache_path` fields must be added to +> **both** types and the conversion must map them explicitly. Verify by grepping for +> where `AuctionBid` is constructed and where it is converted to `Bid`; if they are the +> same type (a type alias), only one struct needs the new fields. If they differ, both +> need updating or the fields will silently be `None` in `build_bid_map`. + +Test files: +| File | Change | +|---|---| +| `crates/trusted-server-core/src/integrations/prebid.rs` tests | Add test: PBS response with cache entry → correct `hb_adid`, `hb_cache_host`, `hb_cache_path` injected | +| `crates/trusted-server-core/src/publisher.rs` tests | Add test: `build_bid_map` emits cache fields when present; falls back to `ad_id` when absent | + +--- + +## 7. Testing + +**Unit tests:** + +1. `prebid.rs`: bid with `ext.prebid.cache.bids.cacheId` → `bid.cache_id = Some(uuid)`, `bid.cache_host = Some("openads.adsrvr.org")`, `bid.cache_path = Some("/cache")` +2. `prebid.rs`: bid without `ext.prebid.cache` → `bid.cache_id = None`, `bid.cache_host = None`, `bid.cache_path = None` +3. `prebid.rs`: bid with only `adid` (no cache) → `bid.ad_id = Some(...)`, `bid.cache_id = None` +4. `prebid.rs`: bid with malformed cache URL → `cache_host = None`, `cache_path = None`, no panic +5. `publisher.rs` `build_bid_map`: bid with `cache_id` → `hb_adid` uses `cache_id`, `hb_cache_host`/`hb_cache_path` emitted +6. `publisher.rs` `build_bid_map`: bid with no `cache_id` but has `ad_id` → `hb_adid` falls back to `ad_id`, no cache keys emitted +7. `publisher.rs` `build_bid_map`: APS bid (no `cache_id`, no `ad_id`) → no `hb_adid` emitted +8. `types.rs`: `Bid` with all three cache fields round-trips through `serde_json::to_string` / `from_str` + +> **Note for implementer:** `make_bid()` or equivalent `Bid` construction helpers in test modules +> must be updated to initialise `cache_id`, `cache_host`, `cache_path` to `None` +> (they will fail to compile otherwise once the fields are added to the struct). + +**Integration verification (manual):** + +After deploying, verify `window.tsjs.bids` in browser devtools shows `hb_cache_host` +and `hb_cache_path` present. Verify `hb_adid` matches the UUID in +`ext.prebid.cache.bids.cacheId` from the raw PBS response. + +--- + +## 8. Rollout Dependency Checklist + +Before this fix has end-to-end effect: + +- [ ] TS: this PR merged and deployed +- [ ] GAM: publisher ad ops updates all Prebid line item creatives to the server-side + cache-fetch variant (see §4.6) +- [ ] PBS: Prebid Cache enabled and populated (confirmed from real response — already + working) +- [ ] Verify: `window.tsjs.bids` shows correct cache UUID in `hb_adid` after deploy + +--- + +## 9. Known Remaining Gaps (not in scope) + +| Gap | Severity | Tracking | +| ----------------------------------------------------------------- | -------- | ------------------ | +| APS win detection over-fires nurl/burl | P1 | Separate issue | +| Dual bootstrap (`gpt_bootstrap.js` + `installTsAdInit`) sync risk | P2 | Separate issue | +| Slim-Prebid bundle not yet built | Phase 2 | §9.8 of design doc | diff --git a/trusted-server.toml b/trusted-server.toml index 899c8c89..ed86a2df 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -125,6 +125,7 @@ enabled = false script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true +# slim_prebid_url = "https://cdn.example.com/tsjs-prebid.min.js" # Consent forwarding configuration # Controls how Trusted Server interprets and forwards privacy consent signals. @@ -186,7 +187,7 @@ rewrite_script = true enabled = true providers = ["prebid", "aps"] mediator = "adserver_mock" -timeout_ms = 2000 +timeout_ms = 2000 # override per-publisher via TRUSTED_SERVER__AUCTION__TIMEOUT_MS # Context keys the JS client is allowed to forward into auction requests. # Keys not in this list are silently dropped. An empty list blocks all keys. allowed_context_keys = ["permutive_segments"] @@ -195,7 +196,7 @@ allowed_context_keys = ["permutive_segments"] enabled = true pub_id = "test-pub" endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" -timeout_ms = 1000 +timeout_ms = 1000 # override per-publisher via TRUSTED_SERVER__INTEGRATIONS__APS__TIMEOUT_MS [integrations.google_tag_manager] enabled = false @@ -212,6 +213,10 @@ timeout_ms = 1000 # Inject before . # Visible in page source. Disable after investigation. # auction_html_comment = true +# +# Inject raw adm creative markup into window.tsjs.bids for GPT/GAM bridge +# debugging while PBS Cache is unavailable. NEVER enable in production. +inject_adm_for_testing = true # Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. # Returns a plain-text response with the following fields (Fastly-observed values): # ja4 — JA4 TLS client fingerprint @@ -242,6 +247,53 @@ gam_network_id = "88059007" # drains in <50 ms but the auction runs to the limit. 500 ms is the recommended # default; raise only if your SSPs need more headroom and your analytics confirm # the DCL slip is acceptable. -auction_timeout_ms = 1500 +auction_timeout_ms = 1500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS price_granularity = "dense" +# Slot templates — override entire array via: +# TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' + +[[creative_opportunities.slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/a/b/news" +div_id = "div-ad-atf-sidebar" +page_patterns = ["/20**", "/news/**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" + +[[creative_opportunities.slot]] +id = "homepage_header_ad" +gam_unit_path = "/a/b/homepage" +div_id = "div-ad-homepage-header" +page_patterns = ["/"] +formats = [{ width = 728, height = 90 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "header" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-homepage-header" + +[[creative_opportunities.slot]] +id = "homepage_footer_ad" +gam_unit_path = "/a/b/homepage" +div_id = "div-ad-homepage-footer" +page_patterns = ["/"] +formats = [{ width = 728, height = 90 }, { width = 768, height = 66 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "btf" +zone = "fixedBottom" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-homepage-footer"