diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 024ec6d2..30b22139 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -38,6 +38,22 @@ import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; const APS_BIDDER_CODE = 'aps'; +// Carrier field for the APS bid-by-reference renderer descriptor: set by +// `auctionBidsToPrebidBids` (interpretResponse), consumed and scrubbed by the +// registry listener installed in `installApsBidResponseRegistry`. +// +// The descriptor is deliberately carried twice on each built bid — as this +// custom top-level field and as `meta[APS_RENDERER_FIELD]`: +// - `meta` is a first-class Prebid bid field (bidderFactory assigns +// `bid.meta = bidResponse.meta` onto the normalized bid, the same guarantee +// `requestId` has), so it survives builds whose normalization drops unknown +// top-level fields (observed in production: the top-level field was absent +// as early as `bidAccepted`). +// - The top-level copy is kept as belt-and-braces for builds that preserve it +// (the vendored prebid.js does) against a future build filtering `meta` +// sub-keys instead. +// The listener registers whichever copy it finds and unconditionally scrubs +// both after the registration attempt. const APS_RENDERER_FIELD = 'trustedServerRenderer'; const APS_BID_RESPONSE_LISTENER_SENTINEL = '__tsApsBidResponseListenerInstalled'; // OpenRTB permits vendor-specific agent types; PAIR uses 571187. @@ -223,9 +239,10 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: } const origReq = requestsByCode.get(bid.impid); + const requestId = origReq?.bidId ?? bid.impid; return [ { - requestId: origReq?.bidId ?? bid.impid, + requestId, cpm: bid.price, width: bid.width, height: bid.height, @@ -238,6 +255,8 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: bidderCode: bid.seat, meta: { advertiserDomains: bid.adomain, + // Second descriptor carrier — see APS_RENDERER_FIELD for the rationale. + ...(renderer ? { [APS_RENDERER_FIELD]: renderer } : {}), }, }, ]; @@ -824,14 +843,21 @@ function installApsBidResponseRegistry(): void { const prebid = pbjs as typeof pbjs & Record; if (prebid[APS_BID_RESPONSE_LISTENER_SENTINEL] === true) return; - pbjs.onEvent('bidResponse', (rawBid) => { - const bid = rawBid as unknown as Record; - const renderer = bid[APS_RENDERER_FIELD]; - if ( - bid['adapterCode'] !== ADAPTER_CODE || - bid['bidderCode'] !== APS_BIDDER_CODE || - renderer === undefined - ) { + const registerFromBid = (rawBid: unknown): void => { + const bid = rawBid as Record; + if (bid['adapterCode'] !== ADAPTER_CODE || bid['bidderCode'] !== APS_BIDDER_CODE) { + return; + } + // Prefer the custom top-level field; fall back to the per-bid copy in `meta` + // — see APS_RENDERER_FIELD for why both carriers exist. Guard the `meta` + // read: a module may have overwritten it with a non-object value. + const rawMeta = bid['meta']; + const meta = + typeof rawMeta === 'object' && rawMeta !== null + ? (rawMeta as Record) + : undefined; + const renderer = bid[APS_RENDERER_FIELD] ?? meta?.[APS_RENDERER_FIELD]; + if (renderer === undefined) { return; } @@ -848,10 +874,20 @@ function installApsBidResponseRegistry(): void { // Keep the executable capability only in the bounded, one-time registry. Prebid // still owns the generated ad ID and ordinary GAM targeting on this bid object. delete bid[APS_RENDERER_FIELD]; + if (meta) { + delete meta[APS_RENDERER_FIELD]; + } if (!registered) { log.warn('[tsjs-prebid] rejected APS renderer capability that failed registration'); } - }); + }; + + // Register on `bidAccepted` — the first event after Prebid assigns `adId` — so + // the executable descriptor is scrubbed from the bid before `bidResponse` and + // analytics consumers of later events can observe it. The `bidResponse` pass + // is a fallback that no-ops when the `bidAccepted` pass already scrubbed. + pbjs.onEvent('bidAccepted', registerFromBid); + pbjs.onEvent('bidResponse', registerFromBid); prebid[APS_BID_RESPONSE_LISTENER_SENTINEL] = true; } diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index a0ba360e..305c66f0 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -229,6 +229,10 @@ describe('prebid/auctionBidsToPrebidBids', () => { bidderCode: 'aps', ad: '', trustedServerRenderer: renderer, + meta: { + advertiserDomains: ['advertiser.example'], + trustedServerRenderer: renderer, + }, }) ); }); @@ -387,6 +391,224 @@ describe('prebid/installPrebidNpm', () => { ); }); + it('registers APS renderer via meta when Prebid strips the custom top-level field', () => { + installPrebidNpm(); + + const bidResponseListener = mockOnEvent.mock.calls.find( + ([eventName]) => eventName === 'bidResponse' + )?.[1] as ((bid: Record) => void) | undefined; + expect(bidResponseListener).toBeTypeOf('function'); + + const renderer = apsRenderer(); + const [built] = auctionBidsToPrebidBids( + [ + { + impid: 'div-aps', + renderer, + price: 1.0, + width: 300, + height: 250, + seat: 'aps', + creativeId: 'cr-aps', + adomain: [], + }, + ], + [{ adUnitCode: 'div-aps', bidId: 'req-strip' }] + ); + + // Prebid delivered the bid with the custom top-level field REMOVED — only + // first-class fields (requestId, meta) survive normalization. + const delivered: Record = { + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'stripped-field-ad-id', + adUnitCode: 'div-aps', + ttl: 300, + requestId: built.requestId, + meta: built.meta, + }; + bidResponseListener!(delivered); + + const entry = (window as any).tsjs.apsPrebidRenderers['stripped-field-ad-id']; + expect(entry).toEqual( + expect.objectContaining({ adUnitCode: 'div-aps', renderer, markWinner: expect.any(Function) }) + ); + // The capability is scrubbed from the delivered bid after registration. + expect(delivered.meta).not.toHaveProperty('trustedServerRenderer'); + }); + + it('registers a distinct renderer for each of multiple APS bids on one imp', () => { + installPrebidNpm(); + + const bidResponseListener = mockOnEvent.mock.calls.find( + ([eventName]) => eventName === 'bidResponse' + )?.[1] as ((bid: Record) => void) | undefined; + expect(bidResponseListener).toBeTypeOf('function'); + + // Two APS bids for the same imp share a requestId; each built bid must carry + // its own descriptor so neither registration is lost. + const firstRenderer = { ...apsRenderer(), creativeId: 'cr-aps-first' }; + const secondRenderer = { ...apsRenderer(), creativeId: 'cr-aps-second' }; + const sharedBid = { + impid: 'div-aps', + price: 1.0, + width: 300, + height: 250, + seat: 'aps', + adomain: [], + }; + const built = auctionBidsToPrebidBids( + [ + { ...sharedBid, renderer: firstRenderer, creativeId: 'cr-aps-first' }, + { ...sharedBid, renderer: secondRenderer, creativeId: 'cr-aps-second' }, + ], + [{ adUnitCode: 'div-aps', bidId: 'req-shared' }] + ); + expect(built).toHaveLength(2); + + for (const [index, bid] of built.entries()) { + bidResponseListener!({ + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: `shared-imp-ad-id-${index}`, + adUnitCode: 'div-aps', + ttl: 300, + requestId: bid.requestId, + meta: bid.meta, + }); + } + + const registry = (window as any).tsjs.apsPrebidRenderers; + expect(registry['shared-imp-ad-id-0']).toEqual( + expect.objectContaining({ renderer: firstRenderer }) + ); + expect(registry['shared-imp-ad-id-1']).toEqual( + expect.objectContaining({ renderer: secondRenderer }) + ); + }); + + it('does not register anything for a stripped bid that carries no meta descriptor', () => { + installPrebidNpm(); + + const bidResponseListener = mockOnEvent.mock.calls.find( + ([eventName]) => eventName === 'bidResponse' + )?.[1] as ((bid: Record) => void) | undefined; + expect(bidResponseListener).toBeTypeOf('function'); + + // First bid registers through the surviving custom-field path. + bidResponseListener!({ + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'surviving-field-ad-id', + adUnitCode: 'div-aps', + ttl: 300, + requestId: 'req-reused', + trustedServerRenderer: apsRenderer(), + }); + expect((window as any).tsjs.apsPrebidRenderers['surviving-field-ad-id']).toBeDefined(); + + // A later field-stripped bid reusing the same requestId has no descriptor of its + // own, so no stale renderer may be registered for it. + bidResponseListener!({ + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'reused-request-ad-id', + adUnitCode: 'div-aps', + ttl: 300, + requestId: 'req-reused', + meta: { advertiserDomains: [] }, + }); + expect((window as any).tsjs.apsPrebidRenderers['reused-request-ad-id']).toBeUndefined(); + }); + + it('registers and scrubs on bidAccepted before later events can observe the descriptor', () => { + installPrebidNpm(); + + const bidAcceptedListener = mockOnEvent.mock.calls.find( + ([eventName]) => eventName === 'bidAccepted' + )?.[1] as ((bid: Record) => void) | undefined; + const bidResponseListener = mockOnEvent.mock.calls.find( + ([eventName]) => eventName === 'bidResponse' + )?.[1] as ((bid: Record) => void) | undefined; + expect(bidAcceptedListener).toBeTypeOf('function'); + expect(bidResponseListener).toBeTypeOf('function'); + + const renderer = apsRenderer(); + const [built] = auctionBidsToPrebidBids( + [ + { + impid: 'div-aps', + renderer, + price: 1.0, + width: 300, + height: 250, + seat: 'aps', + creativeId: 'cr-aps', + adomain: [], + }, + ], + [{ adUnitCode: 'div-aps', bidId: 'req-accepted' }] + ); + + // Prebid emits bidAccepted and bidResponse with the same in-place-mutated + // bid object; the bidAccepted pass must register and scrub both carriers. + const accepted: Record = { + ...built, + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'accepted-ad-id', + adUnitCode: 'div-aps', + }; + bidAcceptedListener!(accepted); + + expect((window as any).tsjs.apsPrebidRenderers['accepted-ad-id']).toEqual( + expect.objectContaining({ adUnitCode: 'div-aps', renderer }) + ); + expect(accepted).not.toHaveProperty('trustedServerRenderer'); + expect(accepted.meta).not.toHaveProperty('trustedServerRenderer'); + + // The later bidResponse pass sees the already-scrubbed object and no-ops. + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + bidResponseListener!(accepted); + expect((window as any).tsjs.apsPrebidRenderers['accepted-ad-id']).toEqual( + expect.objectContaining({ renderer }) + ); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('tolerates a non-object meta value on the bid', () => { + installPrebidNpm(); + + const bidResponseListener = mockOnEvent.mock.calls.find( + ([eventName]) => eventName === 'bidResponse' + )?.[1] as ((bid: Record) => void) | undefined; + expect(bidResponseListener).toBeTypeOf('function'); + + // A module overwrote meta with a string and there is no top-level field: + // nothing registers and nothing throws. + bidResponseListener!({ + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'corrupt-meta-ad-id', + adUnitCode: 'div-aps', + ttl: 300, + meta: 'corrupted', + }); + expect((window as any).tsjs?.apsPrebidRenderers?.['corrupt-meta-ad-id']).toBeUndefined(); + + // With a surviving top-level field the corrupt meta must not block registration. + bidResponseListener!({ + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'corrupt-meta-with-field-ad-id', + adUnitCode: 'div-aps', + ttl: 300, + meta: 'corrupted', + trustedServerRenderer: apsRenderer(), + }); + expect((window as any).tsjs.apsPrebidRenderers['corrupt-meta-with-field-ad-id']).toBeDefined(); + }); + it('does not register malformed or non-trusted APS renderer capabilities', () => { const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); installPrebidNpm();