Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions crates/trusted-server-js/lib/src/integrations/prebid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {}),
},
},
];
Expand Down Expand Up @@ -824,14 +843,21 @@ function installApsBidResponseRegistry(): void {
const prebid = pbjs as typeof pbjs & Record<string, unknown>;
if (prebid[APS_BID_RESPONSE_LISTENER_SENTINEL] === true) return;

pbjs.onEvent('bidResponse', (rawBid) => {
const bid = rawBid as unknown as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>)
: undefined;
const renderer = bid[APS_RENDERER_FIELD] ?? meta?.[APS_RENDERER_FIELD];
Comment thread
aram356 marked this conversation as resolved.
if (renderer === undefined) {
return;
}

Expand All @@ -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;
}

Expand Down
222 changes: 222 additions & 0 deletions crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ describe('prebid/auctionBidsToPrebidBids', () => {
bidderCode: 'aps',
ad: '',
trustedServerRenderer: renderer,
meta: {
advertiserDomains: ['advertiser.example'],
trustedServerRenderer: renderer,
},
})
);
});
Expand Down Expand Up @@ -387,6 +391,224 @@ describe('prebid/installPrebidNpm', () => {
);
});

it('registers APS renderer via meta when Prebid strips the custom top-level field', () => {
Comment thread
aram356 marked this conversation as resolved.
installPrebidNpm();

const bidResponseListener = mockOnEvent.mock.calls.find(
([eventName]) => eventName === 'bidResponse'
)?.[1] as ((bid: Record<string, unknown>) => 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<string, unknown> = {
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', () => {
Comment thread
aram356 marked this conversation as resolved.
installPrebidNpm();

const bidResponseListener = mockOnEvent.mock.calls.find(
([eventName]) => eventName === 'bidResponse'
)?.[1] as ((bid: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => void) | undefined;
const bidResponseListener = mockOnEvent.mock.calls.find(
([eventName]) => eventName === 'bidResponse'
)?.[1] as ((bid: Record<string, unknown>) => 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<string, unknown> = {
...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<string, unknown>) => 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();
Expand Down
Loading