diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts index 927b89a45..e7ee7c1c7 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts @@ -285,6 +285,14 @@ window.ucTag.renderAd(document, { adId: ${JSON.stringify(adId)}, pubUrl: ${JSON. const slot = document.getElementById("div-aps")!; slot.style.width = "1px"; slot.style.height = "1px"; + const outerShell = document.createElement("div"); + outerShell.id = "aps-outer-shell"; + outerShell.style.width = "1px"; + outerShell.style.height = "1px"; + const innerShell = document.createElement("div"); + innerShell.id = "aps-inner-shell"; + innerShell.style.width = "1px"; + innerShell.style.height = "1px"; const frame = document.createElement("iframe"); frame.id = "google_ads_iframe_fictional_0"; frame.width = "1"; @@ -292,7 +300,9 @@ window.ucTag.renderAd(document, { adId: ${JSON.stringify(adId)}, pubUrl: ${JSON. frame.style.width = "1px"; frame.style.height = "1px"; frame.src = outerUrl; - slot.appendChild(frame); + innerShell.appendChild(frame); + outerShell.appendChild(innerShell); + slot.appendChild(outerShell); const other = document.getElementById("div-other")!; const otherFrame = document.createElement("iframe"); @@ -333,6 +343,22 @@ window.ucTag.renderAd(document, { adId: ${JSON.stringify(adId)}, pubUrl: ${JSON. ); await expect(page.locator("#div-aps")).toHaveCSS("width", "300px"); await expect(page.locator("#div-aps")).toHaveCSS("height", "250px"); + await expect(page.locator("#aps-outer-shell")).toHaveCSS( + "width", + "300px", + ); + await expect(page.locator("#aps-outer-shell")).toHaveCSS( + "height", + "250px", + ); + await expect(page.locator("#aps-inner-shell")).toHaveCSS( + "width", + "300px", + ); + await expect(page.locator("#aps-inner-shell")).toHaveCSS( + "height", + "250px", + ); await expect(page.locator("#div-other iframe")).toHaveCSS( "width", "1px", diff --git a/crates/trusted-server-js/lib/src/core/first_impression.ts b/crates/trusted-server-js/lib/src/core/first_impression.ts index fc53dad36..e80ea8753 100644 --- a/crates/trusted-server-js/lib/src/core/first_impression.ts +++ b/crates/trusted-server-js/lib/src/core/first_impression.ts @@ -61,7 +61,15 @@ function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressi continue; } for (const [token, auction] of Object.entries(claim.publisherAuctions)) { - if (auction.expiresAt <= now) removePublisherAuction(state, claim, token, now); + // A TS-owned losing publisher auction remains a fail-closed tombstone for + // this physical element and navigation. Its callback can arrive long after + // the nominal auction lease and must never become an unrelated refresh. + if ( + auction.expiresAt <= now && + !(claim.owner === 'trusted_server' && auction.suppressDelivery) + ) { + removePublisherAuction(state, claim, token, now); + } } if ( claim.owner === 'publisher' && @@ -155,10 +163,11 @@ export function claimFirstImpressionForTrustedServer( } function schedulePublisherAuctionExpiry(ts: TsjsApi, token: string): void { - window.setTimeout( - () => releasePublisherFirstImpressionAuction(ts, token), - FIRST_IMPRESSION_LEASE_MS - ); + window.setTimeout(() => { + // Pruning releases ordinary publisher claims. TS-owned suppression tokens + // deliberately survive as bounded tombstones until navigation/element change. + findPublisherAuction(ts, token); + }, FIRST_IMPRESSION_LEASE_MS); } /** Release a TS claim when slot setup failed before any request could start. */ @@ -211,7 +220,10 @@ export function registerPublisherFirstImpressionAuctions( ) { continue; } - if (claim.owner === 'trusted_server' && (claim.suppressionConsumed || claim.expiresAt <= now)) { + if ( + claim.owner === 'trusted_server' && + (claim.publisherRegistrationClosed || claim.expiresAt <= now) + ) { continue; } if (Object.keys(claim.publisherAuctions).length >= MAX_PUBLISHER_AUCTIONS_PER_SLOT) continue; @@ -275,6 +287,10 @@ export function releasePublisherFirstImpressionAuction( ): void { const found = findPublisherAuction(ts, token, now); if (!found) return; + if (found.claim.owner === 'trusted_server' && found.auction.suppressDelivery) { + found.claim.publisherRegistrationClosed = true; + return; + } found.auction.expiresAt = Math.min(found.auction.expiresAt, now); if ( found.claim.owner === 'publisher' && @@ -295,13 +311,9 @@ export function consumePublisherFirstImpressionDelivery( const found = findPublisherAuction(ts, token, now); if (!found) return false; - const suppress = - found.claim.owner === 'trusted_server' && - found.auction.suppressDelivery && - !found.claim.suppressionConsumed && - found.claim.expiresAt > now; + const suppress = found.claim.owner === 'trusted_server' && found.auction.suppressDelivery; delete found.claim.publisherAuctions[token]; - if (suppress) found.claim.suppressionConsumed = true; + if (suppress) found.claim.publisherRegistrationClosed = true; return suppress; } @@ -329,7 +341,14 @@ export function observeFirstImpressionGptLifecycle( } claim.phase = phase; - if (claim.owner === 'publisher') claim.expiresAt = Number.POSITIVE_INFINITY; + if (claim.owner === 'publisher') { + claim.expiresAt = Number.POSITIVE_INFINITY; + } else { + // Once TS has committed a GPT request, only publisher auctions that were + // already registered can still represent an overlapping first impression. + // New publisher refreshes are ordinary later impressions and must proceed. + claim.publisherRegistrationClosed = true; + } } /** Reserve the only Trusted Server fallback allowed for this physical slot and generation. */ diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 9caaf5b35..e49b66146 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -387,7 +387,8 @@ export interface FirstImpressionSlotClaim { phase: FirstImpressionPhase; expiresAt: number; publisherAuctions: Record; - suppressionConsumed?: boolean; + /** No later publisher auction may join this TS-owned first impression. */ + publisherRegistrationClosed?: boolean; targeting?: Record; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 701f928b2..7d4b4585c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -280,6 +280,22 @@ function usesFixedPositioning(element: HTMLElement): boolean { const MAX_CREATIVE_SHELL_DIMENSION = 10_000; +function creativeFrameIsCurrent( + source: MessageEventSource | null, + frame: MessageSourceFrame, + generation: number, + stillOwnsCreative: () => boolean +): boolean { + return ( + (window.tsjs?.navGeneration ?? 0) === generation && + stillOwnsCreative() && + frame.iframe.isConnected && + frame.root.isConnected && + frame.root.contains(frame.iframe) && + frame.iframe.contentWindow === source + ); +} + /** Resize only the authenticated source iframe for a still-current collapsed display shell. */ function resizeCollapsedCreativeFrame( source: MessageEventSource | null, @@ -290,18 +306,13 @@ function resizeCollapsedCreativeFrame( stillOwnsCreative: () => boolean ): void { if ( - (window.tsjs?.navGeneration ?? 0) !== generation || - !stillOwnsCreative() || + !creativeFrameIsCurrent(source, frame, generation, stillOwnsCreative) || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || width > MAX_CREATIVE_SHELL_DIMENSION || height > MAX_CREATIVE_SHELL_DIMENSION || - !frame.iframe.isConnected || - !frame.root.isConnected || - !frame.root.contains(frame.iframe) || - frame.iframe.contentWindow !== source || frame.iframe.getAttribute('width') !== '1' || frame.iframe.getAttribute('height') !== '1' || !hasCollapsedDimension(frame.iframe, 'width') || @@ -314,24 +325,37 @@ function resizeCollapsedCreativeFrame( return; } - const wrapper = frame.iframe.parentElement; - if ( - !wrapper || - wrapper === document.body || - wrapper === document.documentElement || - !frame.root.contains(wrapper) || - usesFixedPositioning(wrapper) - ) { - return; + const collapsedAncestors: HTMLElement[] = []; + let reachedRoot = false; + for (let ancestor = frame.iframe.parentElement; ancestor; ancestor = ancestor.parentElement) { + if ( + ancestor === document.body || + ancestor === document.documentElement || + !ancestor.isConnected || + usesFixedPositioning(ancestor) || + ancestor.matches( + 'ins[data-anchor-status], [data-google-interstitial], [data-vignette-loaded]' + ) + ) { + return; + } + if (hasCollapsedDimension(ancestor, 'width') || hasCollapsedDimension(ancestor, 'height')) { + collapsedAncestors.push(ancestor); + } + if (ancestor === frame.root) { + reachedRoot = true; + break; + } } + if (!reachedRoot) return; frame.iframe.width = String(width); frame.iframe.height = String(height); frame.iframe.style.width = `${width}px`; frame.iframe.style.height = `${height}px`; - if (hasCollapsedDimension(wrapper, 'width') && hasCollapsedDimension(wrapper, 'height')) { - wrapper.style.width = `${width}px`; - wrapper.style.height = `${height}px`; + for (const ancestor of collapsedAncestors) { + ancestor.style.width = `${width}px`; + ancestor.style.height = `${height}px`; } } @@ -1992,6 +2016,12 @@ export function installTsRenderBridge(): void { trustedServer: (validatedRenderer) => { const rendererUrl = apsRendererUrl(); if (!rendererUrl) return false; + const stillOwnsCreative = () => + sourceFrameForAdUnit(e.source, prebidRendererEntry.adUnitCode)?.iframe === + sourceFrame.iframe; + if (!creativeFrameIsCurrent(e.source, sourceFrame, generation, stillOwnsCreative)) { + return false; + } try { port.postMessage( JSON.stringify({ @@ -2011,11 +2041,9 @@ export function installTsRenderBridge(): void { validatedRenderer.width, validatedRenderer.height, generation, - () => - sourceFrameForAdUnit(e.source, prebidRendererEntry.adUnitCode)?.iframe === - sourceFrame.iframe + stillOwnsCreative ); - return true; + return creativeFrameIsCurrent(e.source, sourceFrame, generation, stillOwnsCreative); } catch (err) { log.warn(`[tsjs-gpt] APS Prebid response post failed for '${adId}'`, err); return false; @@ -2066,6 +2094,13 @@ export function installTsRenderBridge(): void { trustedServer: (validatedRenderer) => { const rendererUrl = apsRendererUrl(); if (!rendererUrl) return false; + const stillOwnsCreative = () => + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe; + if (!creativeFrameIsCurrent(e.source, sourceSlotFrame, generation, stillOwnsCreative)) { + return false; + } try { port.postMessage( JSON.stringify({ @@ -2085,12 +2120,14 @@ export function installTsRenderBridge(): void { validatedRenderer.width, validatedRenderer.height, generation, - () => - window.tsjs?.bids?.[slotId] === matchedBid && - matchedBid.hb_adid === adId && - sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + stillOwnsCreative + ); + return creativeFrameIsCurrent( + e.source, + sourceSlotFrame, + generation, + stillOwnsCreative ); - return true; } catch (err) { log.warn(`[tsjs-gpt] APS server response post failed for '${slotId}'`, err); return false; @@ -2120,6 +2157,15 @@ export function installTsRenderBridge(): void { if (inlineAdm) { e.stopImmediatePropagation(); + const stillOwnsCreative = () => + Boolean( + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + ); + if (!creativeFrameIsCurrent(e.source, sourceSlotFrame, generation, stillOwnsCreative)) { + return; + } try { port.postMessage( JSON.stringify({ @@ -2136,13 +2182,15 @@ export function installTsRenderBridge(): void { log.warn(`[tsjs-gpt] pbRender bridge: response post failed for '${slotId}'`, err); return; } - resizeCollapsedCreativeFrame(e.source, sourceSlotFrame, width, height, generation, () => - Boolean( - window.tsjs?.bids?.[slotId] === matchedBid && - matchedBid.hb_adid === adId && - sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe - ) + resizeCollapsedCreativeFrame( + e.source, + sourceSlotFrame, + width, + height, + generation, + stillOwnsCreative ); + if (!creativeFrameIsCurrent(e.source, sourceSlotFrame, generation, stillOwnsCreative)) return; safelyRecordCreativeResponse(attemptId); fireWinBillingBeacons(slotId, matchedBid); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from inline adm`); @@ -2194,6 +2242,13 @@ export function installTsRenderBridge(): void { : cached.adm; const cachedWidth = cached.width ?? width; const cachedHeight = cached.height ?? height; + const stillOwnsCreative = () => + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe; + if (!creativeFrameIsCurrent(e.source, sourceSlotFrame, generation, stillOwnsCreative)) { + return; + } try { port.postMessage( JSON.stringify({ @@ -2211,16 +2266,16 @@ export function installTsRenderBridge(): void { cachedWidth, cachedHeight, generation, - () => - window.tsjs?.bids?.[slotId] === matchedBid && - matchedBid.hb_adid === adId && - sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + stillOwnsCreative ); } catch (err) { safelyRecordCreativeFailure(attemptId, 'response_post_failed'); log.warn(`[tsjs-gpt] pbRender bridge: response post failed for '${slotId}'`, err); return; } + if (!creativeFrameIsCurrent(e.source, sourceSlotFrame, generation, stillOwnsCreative)) { + return; + } safelyRecordCreativeResponse(attemptId); // Beacons carry the server-expanded ${AUCTION_PRICE} from the auction's // clearing price, not `cached.price` — the auction result is the 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 65cbb0697..ce5020996 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -19,6 +19,7 @@ import { markPublisherFirstImpressionDeliveryPending, registerPublisherFirstImpressionAuctions, releasePublisherFirstImpressionAuction, + resolveFirstImpressionElement, } from '../../core/first_impression'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; @@ -382,12 +383,18 @@ type PendingPublisherBid = { adUnitCode: string; expiresAt: number; registrationId: number; + generation: number; + element: HTMLElement; + retainUntilContextChange: boolean; firstImpressionToken?: string; }; type PendingPublisherCode = { adUnitCode: string; expiresAt: number; registrationId: number; + generation: number; + element: HTMLElement; + retainUntilContextChange: boolean; firstImpressionToken?: string; }; type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; @@ -874,7 +881,8 @@ function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: if (registrationId === undefined) { pendingPublisherCodes.delete(adUnitCode); } else { - registrations.delete(registrationId); + const pending = registrations.get(registrationId); + if (!pending?.retainUntilContextChange) registrations.delete(registrationId); if (registrations.size === 0) pendingPublisherCodes.delete(adUnitCode); } } @@ -882,7 +890,8 @@ function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: for (const [adId, pendingBid] of pendingPublisherBids) { if ( pendingBid.adUnitCode === adUnitCode && - (registrationId === undefined || pendingBid.registrationId === registrationId) + (registrationId === undefined || pendingBid.registrationId === registrationId) && + (registrationId === undefined || !pendingBid.retainUntilContextChange) ) { pendingPublisherBids.delete(adId); if (pendingBid.firstImpressionToken) { @@ -892,17 +901,78 @@ function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: } } +function pendingPublisherContextIsCurrent( + pending: PendingPublisherBid | PendingPublisherCode +): boolean { + return ( + pending.generation === (window.tsjs?.navGeneration ?? 0) && + pending.element.isConnected && + document.getElementById(pending.element.id) === pending.element && + resolvePublisherDeliveryElement(pending.adUnitCode) === pending.element + ); +} + +function resolvePublisherDeliveryElement(adUnitCode: string): HTMLElement | undefined { + const direct = resolveFirstImpressionElement(adUnitCode); + if (direct) return direct; + + const gpt = ( + window as unknown as { + googletag?: { pubads?(): { getSlots?(): RefreshGptSlot[] } }; + } + ).googletag; + const matches = (gpt?.pubads?.().getSlots?.() ?? []) + .filter((slot) => { + const injectedSlot = findInjectedSlotForRefresh(slot); + return refreshSlotElementId(slot) === adUnitCode || injectedSlot?.div_id === adUnitCode; + }) + .map((slot) => { + const elementId = refreshSlotElementId(slot); + return elementId ? document.getElementById(elementId) : null; + }) + .filter((element): element is HTMLElement => Boolean(element?.isConnected)); + return matches.length === 1 ? matches[0] : undefined; +} + +function pendingPublisherContextMatchesSlot( + pending: PendingPublisherBid | PendingPublisherCode, + slot: RefreshGptSlot +): boolean { + if (!pendingPublisherContextIsCurrent(pending)) return false; + const injectedSlot = findInjectedSlotForRefresh(slot); + return [refreshSlotElementId(slot), injectedSlot?.div_id] + .filter((code): code is string => typeof code === 'string' && code.length > 0) + .some((code) => { + const exact = document.getElementById(code); + return ( + exact === pending.element || + Boolean(exact && (pending.element.contains(exact) || exact.contains(pending.element))) || + resolvePublisherDeliveryElement(code) === pending.element + ); + }); +} + /** Discard delivery state that outlived the publisher auction which created it. */ function prunePendingPublisherBids(now = Date.now()): void { for (const [adUnitCode, registrations] of pendingPublisherCodes) { for (const [registrationId, pendingCode] of registrations) { - if (pendingCode.expiresAt <= now) registrations.delete(registrationId); + if ( + !pendingPublisherContextIsCurrent(pendingCode) || + (pendingCode.expiresAt <= now && !pendingCode.retainUntilContextChange) + ) { + registrations.delete(registrationId); + } } if (registrations.size === 0) pendingPublisherCodes.delete(adUnitCode); } for (const [adId, pendingBid] of pendingPublisherBids) { - if (pendingBid.expiresAt <= now) pendingPublisherBids.delete(adId); + if ( + !pendingPublisherContextIsCurrent(pendingBid) || + (pendingBid.expiresAt <= now && !pendingBid.retainUntilContextChange) + ) { + pendingPublisherBids.delete(adId); + } } } @@ -915,8 +985,14 @@ function storePendingPublisherCode(pendingCode: PendingPublisherCode): void { let registrationCount = 0; for (const pending of pendingPublisherCodes.values()) registrationCount += pending.size; if (registrationCount > MAX_PENDING_PUBLISHER_BIDS) { - const oldestCode = pendingPublisherCodes.keys().next().value; - if (oldestCode !== undefined) removePendingPublisherBidsForCode(oldestCode); + for (const [adUnitCode, pendingRegistrations] of pendingPublisherCodes) { + const evictable = [...pendingRegistrations.values()].find( + (pending) => !pending.retainUntilContextChange + ); + if (!evictable) continue; + removePendingPublisherBidsForCode(adUnitCode, evictable.registrationId); + break; + } } } @@ -968,11 +1044,21 @@ function registerPendingPublisherBids( const responseAdIds = publisherResponseAdIds(publisherAdUnitCodes, bidResponses); for (const adUnitCode of publisherAdUnitCodes) { + const element = resolvePublisherDeliveryElement(adUnitCode); + if (!element) continue; const firstImpressionToken = firstImpressionTokens.get(adUnitCode); + const retainUntilContextChange = Boolean( + firstImpressionToken && + window.tsjs && + firstImpressionClaim(window.tsjs, element)?.owner === 'trusted_server' + ); storePendingPublisherCode({ adUnitCode, expiresAt, registrationId, + generation: window.tsjs?.navGeneration ?? 0, + element, + retainUntilContextChange, firstImpressionToken, }); if (firstImpressionToken && window.tsjs) { @@ -985,12 +1071,22 @@ function registerPendingPublisherBids( } for (const [adUnitCode, adIds] of responseAdIds) { + const element = resolvePublisherDeliveryElement(adUnitCode); + if (!element) continue; const firstImpressionToken = firstImpressionTokens.get(adUnitCode); + const retainUntilContextChange = Boolean( + firstImpressionToken && + window.tsjs && + firstImpressionClaim(window.tsjs, element)?.owner === 'trusted_server' + ); for (const adId of adIds) { storePendingPublisherBid(adId, { adUnitCode, expiresAt, registrationId, + generation: window.tsjs?.navGeneration ?? 0, + element, + retainUntilContextChange, firstImpressionToken, }); } @@ -1004,6 +1100,19 @@ interface PublisherDeliveryPartition { suppressedSlots: Set; } +/** Consume the equivalent one-shot suppression owned by the inner GPT wrapper. */ +function consumeGptPublisherRefreshSuppression(slot: RefreshGptSlot): void { + const elementId = refreshSlotElementId(slot); + const handoff = elementId ? window.tsjs?.gptSlotHandoffs?.[elementId] : undefined; + if (handoff?.suppressPublisherRefresh) handoff.suppressPublisherRefresh = false; +} + +/** Restore TS targeting and consume any equivalent GPT-wrapper handoff. */ +function prepareSuppressedPublisherSlot(slot: RefreshGptSlot): void { + restoreTrustedServerFirstImpressionTargeting(slot); + consumeGptPublisherRefreshSuppression(slot); +} + /** Partition correlated publisher deliveries from one losing first-impression delivery. */ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): PublisherDeliveryPartition { prunePendingPublisherBids(); @@ -1016,17 +1125,23 @@ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): PublisherDeliver ? adIds .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) .map((adId) => pendingPublisherBids.get(adId)) - .find((bid): bid is PendingPublisherBid => bid !== undefined) + .find( + (bid): bid is PendingPublisherBid => + bid !== undefined && pendingPublisherContextMatchesSlot(bid, slot) + ) : undefined; const hasAdId = Array.isArray(adIds) && adIds.some((adId) => typeof adId === 'string' && adId.length > 0); const injectedSlot = findInjectedSlotForRefresh(slot); - const pendingCode = hasAdId - ? undefined - : [refreshSlotElementId(slot), injectedSlot?.div_id] - .filter((code): code is string => typeof code === 'string' && code.length > 0) - .flatMap((code) => [...(pendingPublisherCodes.get(code)?.values() ?? [])]) - .sort((left, right) => left.registrationId - right.registrationId)[0]; + const pendingCode = [refreshSlotElementId(slot), injectedSlot?.div_id] + .filter((code): code is string => typeof code === 'string' && code.length > 0) + .flatMap((code) => [...(pendingPublisherCodes.get(code)?.values() ?? [])]) + .filter( + (pending) => + pendingPublisherContextMatchesSlot(pending, slot) && + (!hasAdId || pending.retainUntilContextChange) + ) + .sort((left, right) => left.registrationId - right.registrationId)[0]; const pending = pendingBid ?? pendingCode; if (!pending) continue; @@ -1276,7 +1391,22 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const opts = { ...(requestObj ?? {}) }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; + const explicitAdUnits = (opts as any).adUnits as TrustedServerAdUnit[] | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requestedAdUnitCodes = Array.isArray((opts as any).adUnitCodes) + ? new Set( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((opts as any).adUnitCodes as unknown[]).filter( + (code): code is string => typeof code === 'string' + ) + ) + : undefined; + const adUnits = (explicitAdUnits ?? (pbjs.adUnits as TrustedServerAdUnit[]) ?? []).filter( + (unit) => + explicitAdUnits !== undefined || + requestedAdUnitCodes === undefined || + requestedAdUnitCodes.has(unit.code ?? '') + ); const isSyntheticRefresh = adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); const publisherAdUnitCodes = new Set( @@ -1530,12 +1660,13 @@ export function installRefreshHandler(timeoutMs = 1500): void { } const { deliverySlots, suppressedSlots } = publisherDeliverySlots(targetSlots); - suppressedSlots.forEach(restoreTrustedServerFirstImpressionTargeting); + suppressedSlots.forEach(prepareSuppressedPublisherSlot); const remainingSlots = targetSlots.filter((slot) => !suppressedSlots.has(slot)); if (remainingSlots.length === 0) return; const forwardedSlots = suppressedSlots.size > 0 ? remainingSlots : slots; const independentSlots = remainingSlots.filter((slot) => !deliverySlots.has(slot)); if (independentSlots.length === 0) { + remainingSlots.forEach(consumeGptPublisherRefreshSuppression); recordPrebidRefreshForDiagnostics(remainingSlots); return dispatchPrebidRefresh(originalRefresh, forwardedSlots, opts); } @@ -1551,7 +1682,29 @@ export function installRefreshHandler(timeoutMs = 1500): void { (slot) => !isExcludedFromRefreshAuction(slot, excludedGamAdUnitPathSuffixes) ); if (!auctionSlots.length) { - return originalRefresh(slots, opts); + const immediateSlotCodes = new Map(); + remainingSlots.forEach((slot) => { + const elementId = refreshSlotElementId(slot); + if (elementId) immediateSlotCodes.set(slot, elementId); + }); + const immediateTokens = registerPublisherFirstImpressionAuctions( + (window.tsjs ??= {} as TsjsApi), + immediateSlotCodes.values() + ); + const immediateSuppressedSlots = new Set(); + for (const [slot, elementId] of immediateSlotCodes) { + const token = immediateTokens.get(elementId); + if (token && window.tsjs && consumePublisherFirstImpressionDelivery(window.tsjs, token)) { + immediateSuppressedSlots.add(slot); + } + } + immediateSuppressedSlots.forEach(prepareSuppressedPublisherSlot); + const immediateSlots = remainingSlots.filter((slot) => !immediateSuppressedSlots.has(slot)); + if (immediateSlots.length === 0) return; + immediateSlots.forEach(consumeGptPublisherRefreshSuppression); + const immediateForwardedSlots = + immediateSuppressedSlots.size > 0 ? immediateSlots : forwardedSlots; + return originalRefresh(immediateForwardedSlots, opts); } const adUnits = auctionSlots.map((slot) => { @@ -1594,6 +1747,20 @@ export function installRefreshHandler(timeoutMs = 1500): void { // unrelated GPT slots whose targeting this wrapper only cleared for // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); + const refreshTs = (window.tsjs ??= {} as TsjsApi); + const refreshGeneration = refreshTs.navGeneration ?? 0; + const delayedRefreshCodes = new Map(); + const delayedRefreshElements = new Map(); + remainingSlots.forEach((slot) => { + const elementId = refreshSlotElementId(slot); + if (elementId) delayedRefreshCodes.set(slot, elementId); + const element = elementId ? resolveFirstImpressionElement(elementId) : undefined; + if (element) delayedRefreshElements.set(slot, element); + }); + const refreshFirstImpressionTokens = registerPublisherFirstImpressionAuctions( + refreshTs, + delayedRefreshCodes.values() + ); adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); // Preserve GPT Single Request Architecture: when a publisher refresh @@ -1607,18 +1774,56 @@ export function installRefreshHandler(timeoutMs = 1500): void { if (completed) return; completed = true; if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); + + // The publisher refresh itself started before this asynchronous auction. + // Reconcile its per-slot token only when the callback is ready to issue + // GPT: TS may have won an already-overlapping first impression while the + // auction was pending, while a publisher-first token prevents TS from + // claiming the slot midway through the same refresh. + const callbackFilteredSlots = new Set(); + const callbackSuppressedSlots = new Set(); + for (const slot of remainingSlots) { + const elementId = delayedRefreshCodes.get(slot); + const token = elementId ? refreshFirstImpressionTokens.get(elementId) : undefined; + const element = delayedRefreshElements.get(slot); + const contextIsStale = Boolean( + element && + ((window.tsjs?.navGeneration ?? 0) !== refreshGeneration || + !element.isConnected || + document.getElementById(element.id) !== element) + ); + const suppress = Boolean( + token && window.tsjs && consumePublisherFirstImpressionDelivery(window.tsjs, token) + ); + if (contextIsStale) { + callbackFilteredSlots.add(slot); + } else if (suppress) { + callbackFilteredSlots.add(slot); + callbackSuppressedSlots.add(slot); + } + } + callbackSuppressedSlots.forEach(prepareSuppressedPublisherSlot); + + const completedSlots = remainingSlots.filter((slot) => !callbackFilteredSlots.has(slot)); + if (completedSlots.length === 0) return; + const completedAdUnitCodes = refreshAdUnitCodes.filter( + (_code, index) => !callbackFilteredSlots.has(auctionSlots[index]) + ); if (applyTargeting) { try { - pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); + pbjs.setTargetingForGPTAsync?.(completedAdUnitCodes); } catch (error) { log.error('[tsjs-prebid] refresh targeting failed', error); } } - recordPrebidRefreshForDiagnostics(remainingSlots); + completedSlots.forEach(consumeGptPublisherRefreshSuppression); + recordPrebidRefreshForDiagnostics(completedSlots); // Preserve the publisher's original refresh form unless one losing - // first-impression slot was filtered. A bare call must become explicit - // in that case so GPT cannot re-add the suppressed slot. - dispatchPrebidRefresh(originalRefresh, forwardedSlots, opts); + // first-impression slot was filtered. A delayed bare call must also + // become explicit so slots added after the auction snapshot cannot join. + const completedForwardedSlots = + slots === undefined || callbackFilteredSlots.size > 0 ? completedSlots : forwardedSlots; + dispatchPrebidRefresh(originalRefresh, completedForwardedSlots, opts); } try { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 70a75140e..91bac02b3 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -3230,7 +3230,7 @@ describe('installTsRenderBridge', () => { Object.assign(new Event('message'), { data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), ports: [{ postMessage }], - source: collapsed.source, + source: collapsed.iframe.contentWindow!, stopImmediatePropagation: vi.fn(), }) as unknown as MessageEvent ); @@ -3242,6 +3242,36 @@ describe('installTsRenderBridge', () => { expect(collapsed.wrapper.style.height).toBe('90px'); }); + it('expands every collapsed ancestor through the authenticated slot root', async () => { + const tsjs = (window as TestWindow).tsjs!; + tsjs.bids.homepage_header.adm = '
Fictional creative
'; + tsjs.bids.homepage_header.w = 728; + tsjs.bids.homepage_header.h = 90; + delete tsjs.bids.homepage_header.nurl; + delete tsjs.bids.homepage_header.burl; + const bridgeListener = await captureBridgeListener(); + const collapsed = createCollapsedTrustedSlotIframe(); + const outerWrapper = document.createElement('div'); + outerWrapper.style.width = '1px'; + outerWrapper.style.height = '1px'; + collapsed.slot.insertBefore(outerWrapper, collapsed.wrapper); + outerWrapper.appendChild(collapsed.wrapper); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage: vi.fn() }], + source: collapsed.iframe.contentWindow!, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(collapsed.wrapper.style.width).toBe('728px'); + expect(collapsed.wrapper.style.height).toBe('90px'); + expect(outerWrapper.style.width).toBe('728px'); + expect(outerWrapper.style.height).toBe('90px'); + }); + it.each(['fixed', 'anchor', 'expanded', 'oversized'] as const)( 'does not resize a %s Universal Creative shell', async (guard) => { @@ -4633,6 +4663,13 @@ describe('installTsRenderBridge', () => { }); it('does not resize a stale cache response after navigation', async () => { + const recordTrustedServerCreativeResponse = vi.fn(); + (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { + recordTrustedServerCreativeRequest: vi.fn().mockReturnValue(91), + recordTrustedServerCreativeResponse, + recordTrustedServerCreativeFailure: vi.fn(), + } as unknown as TsjsApi['gptDiagnosticsRecorder']; + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let resolveText: ((body: string) => void) | undefined; fetchStub.mockResolvedValue({ ok: true, @@ -4659,9 +4696,12 @@ describe('installTsRenderBridge', () => { resolveText?.(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(postMessage).toHaveBeenCalledOnce(); + expect(postMessage).not.toHaveBeenCalled(); + expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); + expect(beaconSpy).not.toHaveBeenCalled(); expect(collapsed.iframe.width).toBe('1'); expect(collapsed.iframe.height).toBe('1'); + beaconSpy.mockRestore(); }); it('expands ${AUCTION_PRICE} from the cached bid price before responding', async () => { 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 7b115c925..67d38d9d5 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 @@ -201,7 +201,12 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; -import { claimFirstImpressionForTrustedServer } from '../../../src/core/first_impression'; +import { + claimFirstImpressionForTrustedServer, + consumePublisherFirstImpressionDelivery, + observeFirstImpressionGptLifecycle, + registerPublisherFirstImpressionAuctions, +} from '../../../src/core/first_impression'; import { log } from '../../../src/core/log'; import type { TsjsApi } from '../../../src/core/types'; import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; @@ -1134,6 +1139,34 @@ describe('prebid/installPrebidNpm', () => { }); describe('requestBids shim', () => { + it('limits a global request to opts.adUnitCodes', () => { + const selected = document.createElement('div'); + selected.id = 'selected-global-unit'; + const unselected = document.createElement('div'); + unselected.id = 'unselected-global-unit'; + document.body.append(selected, unselected); + const selectedUnit = { + code: selected.id, + bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], + }; + const unselectedUnit = { + code: unselected.id, + bids: [{ bidder: 'rubicon', params: { accountId: 2 } }], + }; + mockPbjs.adUnits = [selectedUnit, unselectedUnit]; + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ adUnitCodes: [selected.id] } as unknown as RequestBidsArg); + + expect(selectedUnit.bids.map((bid) => bid.bidder)).toEqual(['trustedServer']); + expect(unselectedUnit.bids).toEqual([{ bidder: 'rubicon', params: { accountId: 2 } }]); + expect((testWindow.tsjs as TsjsApi).firstImpression?.slots[selected.id]).toBeDefined(); + expect((testWindow.tsjs as TsjsApi).firstImpression?.slots[unselected.id]).toBeUndefined(); + + selected.remove(); + unselected.remove(); + }); + it('preserves publisher ts adserverTargeting while adding trustedServer settings', () => { const publisherTargeting = [{ key: 'ts', val: () => 'publisher-value' }]; mockPbjs.bidderSettings = { @@ -1461,14 +1494,22 @@ describe('prebid/installRefreshHandler', () => { testWindow.tsjs = undefined; delete testWindow.googletag; delete testWindow.__tsjs_prebid; + document.body.replaceChildren(); }); afterEach(() => { testWindow.tsjs = undefined; delete testWindow.googletag; delete testWindow.__tsjs_prebid; + document.body.replaceChildren(); }); + function attachTestSlot(code: string): void { + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + } + it('builds refresh ad units from injected slot metadata', () => { const originalRefresh = vi.fn(); const gptSlot = { @@ -2088,7 +2129,7 @@ describe('prebid/installRefreshHandler', () => { }) ); expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-display']); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + expect(originalRefresh).toHaveBeenCalledWith(targetSlots, undefined); mockPbjs.setTargetingForGPTAsync = undefined; }); @@ -2279,6 +2320,7 @@ describe('prebid/installRefreshHandler', () => { const pbjs = installPrebidNpm(); const prepareDelivery = (code: string) => { + if (!document.getElementById(code)) attachTestSlot(code); mockRequestBids.mockImplementationOnce((options) => { options.bidsBackHandler?.(); }); @@ -2359,6 +2401,7 @@ describe('prebid/installRefreshHandler', () => { new GptDiagnosticsObserver(store).install(); } const pbjs = installPrebidNpm(); + attachTestSlot('install-order'); mockRequestBids.mockImplementationOnce((options) => options.bidsBackHandler?.()); pbjs.requestBids({ adUnits: [{ code: 'install-order', bids: [{ bidder: 'exampleServer', params: {} }] }], @@ -2407,6 +2450,7 @@ describe('prebid/installRefreshHandler', () => { const pbjs = installPrebidNpm(); installRefreshHandler(750); + attachTestSlot('nested-reentrant'); mockRequestBids.mockImplementation((options) => options.bidsBackHandler?.()); pbjs.requestBids({ adUnits: [{ code: 'nested-reentrant', bids: [{ bidder: 'exampleServer', params: {} }] }], @@ -2489,18 +2533,26 @@ describe('prebid publisher snapshots and delivery refreshes', () => { delete testWindow.__tsjs_prebid; testWindow.tsjs = undefined; delete testWindow.googletag; + document.body.replaceChildren(); }); afterEach(() => { delete testWindow.__tsjs_prebid; testWindow.tsjs = undefined; delete testWindow.googletag; + document.body.replaceChildren(); }); function installGpt(slots: Array>) { installedGptSlots = slots; for (const slot of slots) { if (!slot || typeof slot !== 'object') continue; + const elementId = slot.getSlotElementId?.(); + if (typeof elementId === 'string' && elementId && !document.getElementById(elementId)) { + const element = document.createElement('div'); + element.id = elementId; + document.body.appendChild(element); + } const originalGetTargeting = slot.getTargeting?.bind(slot); slot.getTargeting = (key: string) => { const deliveryAdId = deliveryAdIds.get(slot); @@ -2554,6 +2606,539 @@ describe('prebid publisher snapshots and delivery refreshes', () => { opts?.bidsBackHandler?.(bidResponses, false, auctionId); } + it('suppresses every publisher auction registered before the first TS delivery', () => { + const element = document.createElement('div'); + element.id = 'overlapping-first-impression'; + document.body.appendChild(element); + const ts = {} as TsjsApi; + claimFirstImpressionForTrustedServer(ts, element, 100); + const first = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); + const second = registerPublisherFirstImpressionAuctions(ts, [element.id], 102).get(element.id); + + expect(consumePublisherFirstImpressionDelivery(ts, first, 103)).toBe(true); + expect(consumePublisherFirstImpressionDelivery(ts, second, 104)).toBe(true); + expect(registerPublisherFirstImpressionAuctions(ts, [element.id], 105)).toEqual(new Map()); + + element.remove(); + }); + + it('suppresses a correlated TS-owned delivery after the five-second lease', () => { + const element = document.createElement('div'); + element.id = 'late-first-impression'; + document.body.appendChild(element); + const ts = {} as TsjsApi; + claimFirstImpressionForTrustedServer(ts, element, 100); + const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); + + expect(consumePublisherFirstImpressionDelivery(ts, token, 5_102)).toBe(true); + + element.remove(); + }); + + it('reserves first impression while a publisher refresh auction is pending', () => { + const code = 'pending-publisher-refresh-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + completeRefresh = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([slot]); + + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + expect( + claimFirstImpressionForTrustedServer(ts, document.getElementById(code)!) + ).toBeUndefined(); + expect(originalRefresh).not.toHaveBeenCalled(); + + completeRefresh?.(); + + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('suppresses a delayed publisher refresh when TS already owns first impression', () => { + const code = 'pending-ts-owned-refresh-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + setTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + claimFirstImpressionForTrustedServer(ts, document.getElementById(code)!); + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + completeRefresh = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([slot]); + expect(originalRefresh).not.toHaveBeenCalled(); + + completeRefresh?.(); + + expect(originalRefresh).not.toHaveBeenCalled(); + }); + + it('filters only the TS-owned slot from a delayed mixed publisher refresh', () => { + const tsCode = 'pending-mixed-ts-slot'; + const publisherCode = 'pending-mixed-publisher-slot'; + const tsSlot = { + getSlotElementId: () => tsCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + setTargeting: vi.fn(), + }; + const publisherSlot = { + getSlotElementId: () => publisherCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([tsSlot, publisherSlot]); + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + claimFirstImpressionForTrustedServer(ts, document.getElementById(tsCode)!); + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + completeRefresh = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([tsSlot, publisherSlot]); + completeRefresh?.(); + + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledWith([publisherSlot], undefined); + }); + + it('filters a TS-owned excluded slot from a delayed mixed publisher refresh', () => { + const eligibleCode = 'pending-mixed-eligible-slot'; + const excludedCode = 'pending-mixed-excluded-slot'; + const eligibleSlot = { + getSlotElementId: () => eligibleCode, + getAdUnitPath: () => '/123/content', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const excludedSlot = { + getSlotElementId: () => excludedCode, + getAdUnitPath: () => '/123/trackingonly', + getTargeting: () => [], + getSizes: () => [[1, 1]], + clearTargeting: vi.fn(), + setTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([eligibleSlot, excludedSlot]); + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + claimFirstImpressionForTrustedServer(ts, document.getElementById(excludedCode)!); + testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + completeRefresh = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([eligibleSlot, excludedSlot]); + completeRefresh?.(); + + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledWith([eligibleSlot], undefined); + }); + + it('drops delayed delivery and auction slots together after SPA navigation', () => { + const deliveryCode = 'pending-navigation-delivery-slot'; + const auctionCode = 'pending-navigation-auction-slot'; + const deliverySlot = { + getSlotElementId: () => deliveryCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const auctionSlot = { + getSlotElementId: () => auctionCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([deliverySlot, auctionSlot]); + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + if (opts?.adUnits?.[0]?.code === deliveryCode) { + completePublisherAuction(opts); + } else { + completeRefresh = opts.bidsBackHandler; + } + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: deliveryCode, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([deliverySlot, auctionSlot]), + } as unknown as RequestBidsArg); + ((testWindow.tsjs ??= {}) as unknown as TsjsApi).navGeneration = 1; + completeRefresh?.(); + + expect(originalRefresh).not.toHaveBeenCalled(); + }); + + it('drops a delayed publisher refresh after SPA navigation', () => { + const code = 'pending-previous-navigation-refresh-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + completeRefresh = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([slot]); + ((testWindow.tsjs ??= {}) as unknown as TsjsApi).navGeneration = 1; + completeRefresh?.(); + + expect(originalRefresh).not.toHaveBeenCalled(); + }); + + it('drops a delayed publisher refresh after physical element replacement', () => { + const code = 'pending-replaced-refresh-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + completeRefresh = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([slot]); + document.getElementById(code)?.remove(); + const replacement = document.createElement('div'); + replacement.id = code; + document.body.appendChild(replacement); + completeRefresh?.(); + + expect(originalRefresh).not.toHaveBeenCalled(); + }); + + it('keeps a delayed bare refresh scoped to its captured slot list', () => { + const firstCode = 'pending-bare-first-slot'; + const laterCode = 'pending-bare-later-slot'; + const firstSlot = { + getSlotElementId: () => firstCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const laterSlot = { + getSlotElementId: () => laterCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const slots = [firstSlot]; + const { originalRefresh, pubads } = installGpt(slots); + let completeRefresh: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + completeRefresh = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh(); + slots.push(laterSlot); + completeRefresh?.(); + + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledWith([firstSlot], undefined); + }); + + it('allows publisher refreshes that start after the TS first impression request', () => { + const code = 'requested-ts-owned-refresh-slot'; + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + const ts = {} as TsjsApi; + claimFirstImpressionForTrustedServer(ts, element); + observeFirstImpressionGptLifecycle(ts, element, 'requested'); + + expect(registerPublisherFirstImpressionAuctions(ts, [code])).toEqual(new Map()); + expect(ts.firstImpression?.slots[code]?.publisherRegistrationClosed).toBe(true); + }); + + it('clears a stale GPT handoff when delegating a post-request publisher refresh', () => { + const code = 'post-request-handoff-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + installedGptSlots = [slot]; + const nativeRefresh = vi.fn(); + const ts = (testWindow.tsjs = {} as unknown as PrebidTestWindow['tsjs']) as unknown as TsjsApi; + const handoff = { + gamUnitPath: '/123/post-request', + formats: [[300, 250] as [number, number]], + divIdPrefix: code, + slotElementId: code, + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: true, + }; + ts.gptSlotHandoffs = { [code]: handoff }; + const innerRefresh = vi.fn((slots?: (typeof slot)[]) => { + if (handoff.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + return; + } + nativeRefresh(slots); + }); + const pubads = { refresh: innerRefresh, getSlots: () => [slot] }; + testWindow.googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + claimFirstImpressionForTrustedServer(ts, element); + observeFirstImpressionGptLifecycle(ts, element, 'requested'); + installRefreshHandler(640); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(handoff.suppressPublisherRefresh).toBe(false); + expect(nativeRefresh).toHaveBeenCalledWith([slot]); + }); + + it('suppresses an all-excluded refresh while the TS first impression is pending', () => { + const code = 'pending-all-excluded-slot'; + const slot = { + getSlotElementId: () => code, + getAdUnitPath: () => '/123/trackingonly', + getTargeting: () => [], + getSizes: () => [[1, 1]], + clearTargeting: vi.fn(), + setTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + claimFirstImpressionForTrustedServer(ts, document.getElementById(code)!); + testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(mockRequestBids).not.toHaveBeenCalled(); + expect(originalRefresh).not.toHaveBeenCalled(); + }); + + it('delegates an all-excluded refresh after the TS first impression request', () => { + const code = 'requested-all-excluded-slot'; + const slot = { + getSlotElementId: () => code, + getAdUnitPath: () => '/123/trackingonly', + getTargeting: () => [], + getSizes: () => [[1, 1]], + clearTargeting: vi.fn(), + }; + installedGptSlots = [slot]; + const nativeRefresh = vi.fn(); + const ts = (testWindow.tsjs = {} as unknown as PrebidTestWindow['tsjs']) as unknown as TsjsApi; + const handoff = { + gamUnitPath: '/123/trackingonly', + formats: [[1, 1] as [number, number]], + divIdPrefix: code, + slotElementId: code, + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: true, + }; + ts.gptSlotHandoffs = { [code]: handoff }; + const innerRefresh = vi.fn((slots?: (typeof slot)[]) => { + if (handoff.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + return; + } + nativeRefresh(slots); + }); + const pubads = { refresh: innerRefresh, getSlots: () => [slot] }; + testWindow.googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + claimFirstImpressionForTrustedServer(ts, element); + observeFirstImpressionGptLifecycle(ts, element, 'requested'); + testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; + installRefreshHandler(640); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(mockRequestBids).not.toHaveBeenCalled(); + expect(handoff.suppressPublisherRefresh).toBe(false); + expect(nativeRefresh).toHaveBeenCalledWith([slot]); + }); + + it('consumes late-handoff suppression when Prebid suppresses the same delivery', () => { + const code = 'composed-suppression-slot'; + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + setTargeting: vi.fn(), + }; + installedGptSlots = [slot]; + const nativeRefresh = vi.fn(); + const ts = (testWindow.tsjs = {} as unknown as PrebidTestWindow['tsjs']) as unknown as TsjsApi; + const handoff = { + gamUnitPath: '/123/composed', + formats: [[300, 250] as [number, number]], + divIdPrefix: code, + slotElementId: code, + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: true, + }; + ts.gptSlotHandoffs = { [code]: handoff }; + const innerRefresh = vi.fn((slots?: (typeof slot)[]) => { + if (handoff.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + return; + } + nativeRefresh(slots); + }); + const pubads = { refresh: innerRefresh, getSlots: () => [slot] }; + testWindow.googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + claimFirstImpressionForTrustedServer(ts, element); + installRefreshHandler(640); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot]), + } as unknown as RequestBidsArg); + + expect(handoff.suppressPublisherRefresh).toBe(false); + expect(innerRefresh).not.toHaveBeenCalled(); + + pubads.refresh([slot]); + + expect(nativeRefresh).toHaveBeenCalledWith([slot]); + }); + + it('forwards only unsuppressed excluded slots', () => { + const suppressedCode = 'mixed-suppressed-slot'; + const excludedCode = 'mixed-excluded-slot'; + const suppressedSlot = { + getSlotElementId: () => suppressedCode, + getAdUnitPath: () => '/123/content', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + setTargeting: vi.fn(), + }; + const excludedSlot = { + getSlotElementId: () => excludedCode, + getAdUnitPath: () => '/123/trackingonly', + getTargeting: () => [], + getSizes: () => [[1, 1]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([suppressedSlot, excludedSlot]); + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + claimFirstImpressionForTrustedServer(ts, document.getElementById(suppressedCode)!); + testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: suppressedCode, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([suppressedSlot, excludedSlot]), + } as unknown as RequestBidsArg); + + expect(originalRefresh).toHaveBeenCalledWith([excludedSlot], undefined); + }); + + it('rejects pending delivery state from a previous navigation', () => { + const code = 'previous-navigation-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as unknown as RequestBidsArg); + ((testWindow.tsjs ??= {}) as unknown as TsjsApi).navGeneration = 1; + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('rejects pending delivery state after physical element replacement', () => { + const code = 'replaced-physical-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as unknown as RequestBidsArg); + document.getElementById(code)?.remove(); + const replacement = document.createElement('div'); + replacement.id = code; + document.body.appendChild(replacement); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + function installPrebidRefreshDiagnostics( implementation?: (slots: Array>) => void ) { @@ -2604,7 +3189,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).not.toHaveBeenCalled(); expect(slot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); expect(slot.setTargeting).toHaveBeenCalledWith('hb_adid', 'example-ts-ad-id'); - expect(ts.firstImpression?.slots[code]?.suppressionConsumed).toBe(true); + expect(ts.firstImpression?.slots[code]?.publisherRegistrationClosed).toBe(true); pubads.refresh([slot], { changeCorrelator: false }); @@ -3391,7 +3976,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + expect(originalRefresh).toHaveBeenCalledWith([coveredSlot, gamOnlySlot], undefined); }); it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { @@ -3577,6 +4162,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); + const publisherElement = document.createElement('div'); + publisherElement.id = code; + publisherElement.appendChild(document.getElementById('example-different-gpt-slot')!); + document.body.appendChild(publisherElement); let auctionId = 'example-null-auction'; const setTargetingForGPTAsync = vi.fn(() => { deliveryAdIds.set(slot, `${auctionId}-${code}`); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 6ee858568..7f31f059d 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -86,7 +86,7 @@ describe('tsjs-prebid shim artifact', () => { // A value-import of Prebid or a private rendering helper would multiply // the shim size; retain a margin above the normal compact shim output. expect(bundleCode.length).toBeGreaterThan(200_000); - expect(shimCode.length).toBeLessThan(30_000); + expect(shimCode.length).toBeLessThan(32_000); expect(shimCode).toContain('markWinningBidAsUsed'); }); }); diff --git a/docs/superpowers/plans/2026-08-27-pr-1079-review-remediation.md b/docs/superpowers/plans/2026-08-27-pr-1079-review-remediation.md new file mode 100644 index 000000000..33c6a2832 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-pr-1079-review-remediation.md @@ -0,0 +1,154 @@ +# PR 1079 Review Remediation 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:** Resolve every review finding on PR 1079 and produce an `rc/202608`-based staging branch containing the corrected implementation. + +**Architecture:** Keep the first-claimant state machine, but make suppression token-local and correlation navigation/element-local. The first suppressed delivery closes registration while preserving every already-registered losing token until navigation or element replacement. Compose GPT/Prebid refresh wrappers explicitly, and centralize pre-response creative freshness validation plus safe authenticated-shell expansion. + +**Tech Stack:** TypeScript, Vitest/jsdom, Playwright, esbuild, Rust workspace validation, Git. + +--- + +### Task 1: First-impression token semantics + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/first_impression.ts` +- Test: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +- [ ] **Step 1: Add failing overlap and late-token tests** + +Add tests named `suppresses every publisher auction registered before the first TS delivery` and `suppresses a correlated TS-owned delivery after the five-second lease`. Assert two pre-registered callbacks are both suppressed, a later auction proceeds, and a fake-timer callback after 5 seconds remains suppressed. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "registered before|five-second lease"` + +Expected: FAIL because `suppressionConsumed` permits the second delivery and expiry deletes the late token. + +- [ ] **Step 3: Implement token-local suppression** + +Replace `suppressionConsumed` with a claim-level `publisherRegistrationClosed` flag. Set it on the first suppressed delivery; do not consult it when consuming tokens already registered. Retain unresolved TS-owned suppressing tokens as non-evictable tombstones while generation and exact element identity match, including across timeout and auction failure; prune publisher-owned expired tokens and remove suppressing tombstones only on navigation or element replacement. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run the Step 2 command. Expected: PASS. + +- [ ] **Step 5: Commit the state-machine checkpoint** + +Run: `git add crates/trusted-server-js/lib/src/core/types.ts crates/trusted-server-js/lib/src/core/first_impression.ts crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts && git commit -m "fix(js): make first impression suppression auction local"` + +### Task 2: Prebid request and delivery correlation + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- Test: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +- [ ] **Step 1: Add five failing Prebid regressions** + +Add tests named `consumes late-handoff suppression when Prebid suppresses the same delivery`, `limits a global request to opts.adUnitCodes`, `forwards only unsuppressed excluded slots`, `rejects pending delivery state from a previous navigation`, and `rejects pending delivery state after physical element replacement`. Assert the next legitimate refresh survives composed wrappers; only the selected global unit is mutated/claimed/correlated; a suppressed slot is absent from the native mixed refresh; and stale records neither suppress nor directly forward the new physical slot. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "late-handoff|opts.adUnitCodes|unsuppressed excluded|previous navigation|physical element replacement"` + +Expected: FAIL on the current wrapper, scoping, forwarding, and stale-correlation behavior. + +- [ ] **Step 3: Implement scoped, physical correlation** + +When `opts.adUnits` is absent and `opts.adUnitCodes` is an array, filter `pbjs.adUnits` before snapshotting, mutation, claiming, and correlation. Stamp `PendingPublisherBid` and `PendingPublisherCode` with `navGeneration` and the exact resolved `HTMLElement`; accept them only if generation, element identity, connectivity, DOM lookup, and target-slot resolution still match. Retain still-current suppressing correlations as tombstones. When Prebid suppresses a slot, clear the matching `gptSlotHandoffs` one-shot flag. In the no-auction/excluded branch call native GPT with `forwardedSlots`, not the original list. + +- [ ] **Step 4: Run the full Prebid test file and verify GREEN** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts`. Expected: PASS. + +- [ ] **Step 5: Commit the Prebid checkpoint** + +Run: `git add crates/trusted-server-js/lib/src/integrations/prebid/index.ts crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts && git commit -m "fix(js): scope publisher delivery correlation"` + +### Task 3: Creative freshness and nested shell repair + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- Test: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` + +- [ ] **Step 1: Add failing stale-response and nested-shell tests** + +Change `does not resize a stale cache response after navigation` to assert zero port posts, zero successful-response evidence, and zero billing beacons. Add `expands every collapsed ancestor through the authenticated slot root`, with iframe -> 1x1 inner wrapper -> 1x1 outer wrapper -> authenticated root. Add/extend the browser scenario to assert all clipping ancestors have the winning dimensions. + +- [ ] **Step 2: Run focused GPT tests and verify RED** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/gpt/ad_init.test.ts -t "stale cache response|every collapsed ancestor"` + +Expected: FAIL because stale cache data is posted and only the immediate parent is resized. + +- [ ] **Step 3: Validate before creative side effects** + +Create one helper that checks current generation, winning bid/renderer ownership, authenticated source iframe identity, connectivity, and containment. Invoke it immediately before every APS or ADM `postMessage`; return before successful-response diagnostics, `markUsed`, or billing on failure. + +- [ ] **Step 4: Expand the authenticated shell safely** + +Require finite positive dimensions no larger than 10,000. Require the source iframe to retain its 1x1 attributes and collapsed computed dimensions. Preflight every ancestor through the authenticated root, rejecting detached/foreign roots, `body`/`html`, fixed/sticky positioning, and anchor/vignette/interstitial markers. Then resize the iframe and each ancestor whose width or height remains collapsed; never mutate outside the authenticated root. + +- [ ] **Step 5: Run GPT unit and browser tests** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/gpt/ad_init.test.ts` + +Run: `cd crates/trusted-server-integration-tests/browser && npx playwright test tests/shared/aps-renderer.spec.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit the renderer checkpoint** + +Run: `git add crates/trusted-server-js/lib/src/integrations/gpt/index.ts crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts && git commit -m "fix(js): reject stale creatives and expand nested shells"` + +### Task 4: Full verification + +- [ ] **Step 0: Commit the reviewed design and plan** + +Run: `git add docs/superpowers/specs/2026-08-27-pr-1079-review-remediation-design.md docs/superpowers/plans/2026-08-27-pr-1079-review-remediation.md && git commit -m "docs: plan PR 1079 review remediation"`. + +- [ ] **Step 1: Run JS gates** + +Run from `crates/trusted-server-js/lib`: `npm run format && npm run lint && npx vitest run && node build-all.mjs`. Run the relevant Playwright suite with the command established in Task 3. Expected: every command exits 0. + +- [ ] **Step 2: Run repository Rust gates** + +Run: `cargo fmt --all -- --check`, `cargo test-fastly`, `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin`, `./scripts/test-cli.sh`, `cargo clippy-fastly`, `cargo clippy-axum`, `cargo clippy-cloudflare`, `cargo clippy-cloudflare-wasm`, `cargo clippy-spin-native`, and `cargo clippy-spin-wasm`. Expected: every command exits 0. + +- [ ] **Step 3: Commit formatting or test-only adjustments** + +If verification changed tracked files, review them and commit only scoped changes as `chore: finalize PR 1079 remediation verification`. + +### Task 5: Build the staging branch + +- [ ] **Step 1: Confirm a clean repair branch** + +Run: `git status --short --branch` and record `git rev-parse HEAD`. Expected: branch `fix/gpt-first-impression-aps-shell-review`, no uncommitted changes. + +- [ ] **Step 2: Refresh the remote RC ref** + +Run: `git fetch origin refs/heads/rc/202608:refs/remotes/origin/rc/202608 refs/heads/fix/gpt-first-impression-aps-shell:refs/remotes/origin/fix/gpt-first-impression-aps-shell`. + +- [ ] **Step 3: Create and merge the staging branch** + +Run: `git switch -c staging/202608-pr1079-review origin/rc/202608` then `git merge --no-ff fix/gpt-first-impression-aps-shell-review -m "Merge PR 1079 review remediation for staging"`. Expected: merge succeeds without unresolved conflicts. + +- [ ] **Step 4: Re-run critical post-merge gates** + +Run: `cd crates/trusted-server-js/lib && npm run format && npm run lint && npx vitest run && node build-all.mjs`. + +Run: `cd crates/trusted-server-integration-tests/browser && npx playwright test tests/shared/aps-renderer.spec.ts`. + +Run from the repository root: `cargo fmt --all -- --check && cargo check-fastly && cargo check-axum && cargo check-cloudflare`. + +Expected: every command exits 0 and `git status --short --branch` is clean on `staging/202608-pr1079-review`. + +- [ ] **Step 5: Report deployable refs** + +Record the repair-branch hash, staging merge hash, exact test results, and any non-blocking environment limitations. Do not push unless separately requested. diff --git a/docs/superpowers/specs/2026-08-27-pr-1079-review-remediation-design.md b/docs/superpowers/specs/2026-08-27-pr-1079-review-remediation-design.md new file mode 100644 index 000000000..8f751061a --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-pr-1079-review-remediation-design.md @@ -0,0 +1,75 @@ +# PR 1079 Review Remediation Design + +## Goal + +Make the first-impression ownership and APS creative bridge safe under overlapping +publisher auctions, late callbacks, SPA navigation, mixed GPT refresh lists, and +nested 1x1 GAM shells. Preserve PR 1079's first-claimant policy: Trusted Server may +win an untouched physical slot, but must neither overwrite a publisher impression +nor let a stale response affect a later navigation. + +## Ownership model + +First-impression state remains keyed by navigation generation and exact physical +element identity. Each publisher auction gets an independent token whose +suppression decision is fixed when the auction is registered. When Trusted Server +commits its request, registration closes for new losing publisher auctions, while +already-registered losing tokens remain suppressible. Those tokens remain as +tombstones for the lifetime of the same navigation and exact physical element. +Unresolved suppressing tombstones are never evicted or removed by timeout or +auction failure; only navigation change or physical element replacement removes +them. The existing per-slot registration limit bounds the set before registration +closes, so an arbitrarily late correlated callback cannot become unrelated. + +Prebid's pending bid/code correlation records carry the navigation generation and +physical element identity captured at registration. A record is usable only while +both still match. Scoped `requestBids({ adUnitCodes })` calls inspect, mutate, +claim, and correlate only those requested global ad units. + +## Refresh suppression + +The Prebid delivery wrapper is the owner of first-impression delivery suppression. +When it suppresses a GPT slot, it also consumes any equivalent late-handoff +one-shot flag so the inner GPT wrapper cannot suppress the next legitimate +refresh. When it delegates a permitted GPT request, it consumes that flag at the +delegation boundary so the inner wrapper cannot silently drop the request. Mixed +refresh calls always forward the already-filtered slot list, including the path +where every remaining slot is excluded from a Prebid auction. That all-excluded +path performs the same ownership registration and consumption synchronously +before delegating. A bare refresh delayed by an auction becomes an explicit list +at callback time, preventing slots added after the snapshot from joining it. + +A publisher-triggered GPT refresh that starts a synthetic Prebid auction registers +its own per-slot first-impression tokens before waiting for the asynchronous +callback. A publisher-first token reserves the slot so TS cannot claim it while +the auction is pending. A token registered against an earlier TS claim is consumed +at callback time, filtering that slot from the eventual GPT request. When TS emits +its first GPT request, registration closes for new losing publisher tokens so +ordinary later publisher refreshes continue normally. Mixed callbacks forward +only their unsuppressed slots and scope Prebid targeting to the same filtered set. +The callback also revalidates the captured navigation generation and exact +physical element, dropping stale work rather than refreshing a replacement slot. + +## Creative bridge + +Every asynchronous renderer/cache result is revalidated before posting a creative +response or recording successful response/billing evidence. A stale result may be +recorded as safe failure telemetry, but is never recorded as a response or win. +Validation covers navigation +generation, winning bid identity, authenticated source iframe identity, DOM +connectivity, and containment in the authenticated slot root. + +After a valid response is posted, a collapsed 1x1 source iframe is expanded to the +winning creative size. The bridge walks all collapsed ancestors through the +authenticated slot root and expands each clipping shell. It refuses all resizing +for fixed/sticky, anchor, vignette, interstitial, detached, oversized, or +otherwise unauthenticated shells. + +## Verification + +Regression tests cover all seven review findings, including wrapper composition, +scoped ad-unit requests, mixed excluded refreshes, stale SPA callbacks, +overlapping auctions, stale cache responses with no successful response/billing +evidence, and two nested +collapsed ancestors. Existing JS unit/browser suites, formatting, lint, build, +and repository Rust verification remain the completion gates.