From 6cc9983cc9d65fcd0d45009bd54c05315520741d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:11:42 -0700 Subject: [PATCH 1/2] Register APS renderer via bid.meta so it survives Prebid field stripping APS bids are bid-by-reference: interpretResponse sets the renderer descriptor on the Prebid bid as the custom trustedServerRenderer field, and a bidResponse listener registers it in window.tsjs.apsPrebidRenderers keyed by Prebid's generated adId so the Universal Creative can later request it. Prebid normalizes each bid into its own object during addBidResponse and drops unknown top-level fields, so the custom field can be gone before the bidResponse listener runs (observed in production: absent as early as bidAccepted). The listener then saw renderer === undefined and returned without registering, leaving the registry empty; the Universal Creative's request found nothing and Prebid's default renderer threw "Missing ad markup or URL" (reason noAd) for every APS bid. Carry the descriptor in bid.meta as well. meta is a first-class field Prebid preserves through normalization (bidderFactory assigns bid.meta onto the normalized bid), and it is per bid, so multiple APS bids on the same imp each keep their own descriptor. The bidResponse listener falls back to the meta copy when the top-level field is absent and scrubs both copies unconditionally after the registration attempt, keeping the executable capability only in the bounded one-time registry. Adds unit tests for registration via meta with the top-level field stripped, distinct renderer registration for multiple APS bids on one imp, and that a stripped bid carrying no descriptor registers nothing. --- .../lib/src/integrations/prebid/index.ts | 33 ++++- .../test/integrations/prebid/index.test.ts | 134 ++++++++++++++++++ 2 files changed, 160 insertions(+), 7 deletions(-) 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 024ec6d22..d4265e7ff 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -196,6 +196,15 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { /** Resolved endpoint — set by installPrebidNpm, read by the adapter. */ let auctionEndpoint = '/auction'; +// Prebid normalizes each bid into its own internal object during `addBidResponse`, +// which can drop unknown top-level fields — so the custom `trustedServerRenderer` +// descriptor set in `interpretResponse` may be gone by the time the `bidResponse` +// listener runs (observed in production: the field is absent as early as `bidAccepted`). +// `meta` is a first-class field Prebid preserves through that normalization (its +// bidderFactory assigns `bid.meta = bidResponse.meta` onto the normalized bid), so the +// descriptor is also carried as `meta[APS_RENDERER_FIELD]` per bid and the `bidResponse` +// listener falls back to it. Both copies are scrubbed after the registration attempt. + /** * Convert parsed {@link AuctionBid}s into Prebid bid response objects, * linking each bid back to the original BidRequest via `requestId`. @@ -223,9 +232,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 +248,9 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: bidderCode: bid.seat, meta: { advertiserDomains: bid.adomain, + // Carry the descriptor in `meta` too so registration survives Prebid + // stripping the custom top-level field. + ...(renderer ? { [APS_RENDERER_FIELD]: renderer } : {}), }, }, ]; @@ -826,12 +839,15 @@ function installApsBidResponseRegistry(): void { 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 - ) { + 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` when + // Prebid has stripped the top-level one during bid normalization (the field is + // often gone before this listener runs). + const meta = bid['meta'] as Record | undefined; + const renderer = bid[APS_RENDERER_FIELD] ?? meta?.[APS_RENDERER_FIELD]; + if (renderer === undefined) { return; } @@ -848,6 +864,9 @@ 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'); } 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 a0ba360ed..195be598c 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,136 @@ 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('does not register malformed or non-trusted APS renderer capabilities', () => { const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); installPrebidNpm(); From d3c850d6e275ff7e25187a363b247a66aa340c3f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:43:48 -0700 Subject: [PATCH 2/2] Register APS renderer on bidAccepted and consolidate carrier rationale - Register and scrub the APS renderer descriptor on bidAccepted, the first event after Prebid assigns adId, so bidResponse and analytics consumers of later events never observe the meta copy; the bidResponse pass remains as a fallback that no-ops once the bidAccepted pass has scrubbed. - Guard the meta read with a narrow object check so a module overwriting meta with a non-object value cannot break registration or scrubbing. - Consolidate the dual-carrier rationale onto APS_RENDERER_FIELD, stating the belt-and-braces intent of keeping the custom top-level copy; both call sites now reference it in one line. --- .../lib/src/integrations/prebid/index.ts | 53 +++++++---- .../test/integrations/prebid/index.test.ts | 88 +++++++++++++++++++ 2 files changed, 123 insertions(+), 18 deletions(-) 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 d4265e7ff..30b22139b 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. @@ -196,15 +212,6 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { /** Resolved endpoint — set by installPrebidNpm, read by the adapter. */ let auctionEndpoint = '/auction'; -// Prebid normalizes each bid into its own internal object during `addBidResponse`, -// which can drop unknown top-level fields — so the custom `trustedServerRenderer` -// descriptor set in `interpretResponse` may be gone by the time the `bidResponse` -// listener runs (observed in production: the field is absent as early as `bidAccepted`). -// `meta` is a first-class field Prebid preserves through that normalization (its -// bidderFactory assigns `bid.meta = bidResponse.meta` onto the normalized bid), so the -// descriptor is also carried as `meta[APS_RENDERER_FIELD]` per bid and the `bidResponse` -// listener falls back to it. Both copies are scrubbed after the registration attempt. - /** * Convert parsed {@link AuctionBid}s into Prebid bid response objects, * linking each bid back to the original BidRequest via `requestId`. @@ -248,8 +255,7 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: bidderCode: bid.seat, meta: { advertiserDomains: bid.adomain, - // Carry the descriptor in `meta` too so registration survives Prebid - // stripping the custom top-level field. + // Second descriptor carrier — see APS_RENDERER_FIELD for the rationale. ...(renderer ? { [APS_RENDERER_FIELD]: renderer } : {}), }, }, @@ -837,15 +843,19 @@ 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 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` when - // Prebid has stripped the top-level one during bid normalization (the field is - // often gone before this listener runs). - const meta = bid['meta'] as Record | undefined; + // 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; @@ -870,7 +880,14 @@ function installApsBidResponseRegistry(): void { 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 195be598c..305c66f0d 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 @@ -521,6 +521,94 @@ describe('prebid/installPrebidNpm', () => { 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();