From 002cbf2968d160259427283a2b5c69a3a245dde5 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 26 Aug 2026 17:58:52 -0500 Subject: [PATCH 1/3] Arbitrate GPT first impressions and resize PUC shells --- .../src/integrations/gpt.rs | 10 +- .../src/integrations/gpt_bootstrap.js | 288 ++++- .../browser/package-lock.json | 1065 ++++++++++++++++- .../browser/package.json | 3 +- .../browser/tests/shared/aps-renderer.spec.ts | 146 +++ .../lib/src/core/first_impression.ts | 358 ++++++ .../trusted-server-js/lib/src/core/types.ts | 38 + .../lib/src/integrations/gpt/index.ts | 415 ++++++- .../lib/src/integrations/prebid/index.ts | 251 +++- .../lib/test/integrations/gpt/ad_init.test.ts | 269 ++++- .../integrations/gpt/gpt_bootstrap.test.ts | 59 + .../test/integrations/prebid/index.test.ts | 56 + docs/guide/integrations/aps.md | 4 +- ...6-04-15-server-side-ad-templates-design.md | 10 +- ...vent-duplicate-gpt-slot-requests-design.md | 35 +- 15 files changed, 2843 insertions(+), 164 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/first_impression.ts diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 84158c27e..9a0905455 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1246,12 +1246,16 @@ mod tests { "should set ts_initial sentinel" ); assert!( - !combined.contains("addEventListener(\"slotRenderEnded\""), - "inline bootstrap cannot prove TS creative rendering from GPT slotRenderEnded" + combined.contains("addEventListener(\"slotRequested\""), + "should observe publisher GPT requests before delayed adInit" + ); + assert!( + combined.contains("addEventListener(\"slotRenderEnded\""), + "should observe publisher GPT renders before delayed adInit" ); assert!( !combined.contains("sendBeacon"), - "inline bootstrap must not fire win/billing beacons from GPT slotRenderEnded" + "inline bootstrap lifecycle ownership must not fire win/billing beacons" ); assert!( !combined.contains("getTargeting(\"hb_adid\")"), diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 2475c5082..883848509 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -102,6 +102,137 @@ pubads.__tsInitialLoadHooked = true; }); + var FIRST_IMPRESSION_LEASE_MS = 5000; + + function firstImpressionState(now) { + var generation = ts.navGeneration || 0; + if ( + !ts.firstImpression || + ts.firstImpression.generation !== generation + ) { + ts.firstImpression = { + generation: generation, + nextToken: 0, + slots: {}, + fallbackSlots: {}, + }; + } + var state = ts.firstImpression; + state.slots = state.slots || {}; + state.fallbackSlots = state.fallbackSlots || {}; + Object.keys(state.slots).forEach(function (elementId) { + var claim = state.slots[elementId]; + if ( + claim.generation !== generation || + claim.slotElementId !== elementId || + claim.element !== document.getElementById(elementId) || + !claim.element.isConnected + ) { + delete state.slots[elementId]; + return; + } + Object.keys(claim.publisherAuctions || {}).forEach(function (token) { + if (claim.publisherAuctions[token].expiresAt <= now) { + delete claim.publisherAuctions[token]; + } + }); + if ( + claim.owner === "publisher" && + (claim.phase === "auctioning" || claim.phase === "delivery_pending") && + Object.keys(claim.publisherAuctions || {}).length === 0 && + claim.expiresAt <= now + ) { + delete state.slots[elementId]; + } + }); + Object.keys(state.fallbackSlots).forEach(function (elementId) { + var element = state.fallbackSlots[elementId]; + if ( + !element.isConnected || + element.id !== elementId || + document.getElementById(elementId) !== element + ) { + delete state.fallbackSlots[elementId]; + } + }); + return state; + } + + function firstImpressionClaim(element) { + return firstImpressionState(Date.now()).slots[element.id]; + } + + function claimFirstImpressionForTrustedServer(element) { + var now = Date.now(); + var state = firstImpressionState(now); + if (state.slots[element.id]) return null; + var claim = { + generation: state.generation, + slotElementId: element.id, + element: element, + owner: "trusted_server", + phase: "delivery_pending", + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + publisherAuctions: {}, + }; + state.slots[element.id] = claim; + return claim; + } + + function releaseTrustedServerFirstImpressionClaim(element, claim) { + var state = firstImpressionState(Date.now()); + if ( + state.slots[element.id] === claim && + claim.owner === "trusted_server" && + claim.phase === "delivery_pending" && + Object.keys(claim.publisherAuctions || {}).length === 0 + ) { + delete state.slots[element.id]; + } + } + + function installFirstImpressionListeners() { + if (ts.firstImpressionListenersInstalled) return; + tag.cmd.push(function () { + if (ts.firstImpressionListenersInstalled) return; + var pubads = window.googletag.pubads(); + if (!pubads || typeof pubads.addEventListener !== "function") return; + var observe = function (phase) { + return function (event) { + var elementId = + event.slot && event.slot.getSlotElementId + ? event.slot.getSlotElementId() + : ""; + var element = elementId && document.getElementById(elementId); + if (!element) return; + var state = firstImpressionState(Date.now()); + var claim = state.slots[elementId]; + if (!claim) { + claim = state.slots[elementId] = { + generation: state.generation, + slotElementId: elementId, + element: element, + owner: "publisher", + phase: phase, + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }; + } else { + claim.phase = phase; + if (claim.owner === "publisher") { + claim.expiresAt = Number.POSITIVE_INFINITY; + } + } + }; + }; + pubads.addEventListener("slotRequested", observe("requested")); + pubads.addEventListener("slotRenderEnded", observe("rendered")); + ts.firstImpressionListenersInstalled = true; + }); + } + + installFirstImpressionListeners(); + // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's // hydration-safe scheduler in // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the @@ -412,6 +543,129 @@ installSlotHandoff(); + function bootstrapTargeting(slot, bid) { + var targeting = Object.assign({}, slot.targeting || {}); + ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach( + function (key) { + if (bid[key]) targeting[key] = String(bid[key]); + }, + ); + targeting.ts_initial = "1"; + return targeting; + } + + function scheduleFirstImpressionFallback(slot, bid, element, generation) { + var state = firstImpressionState(Date.now()); + if (state.fallbackSlots[element.id]) return; + state.fallbackSlots[element.id] = element; + + var retry = function () { + if ( + (ts.navGeneration || 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + var claim = firstImpressionClaim(element); + if (claim) { + if ( + claim.owner !== "publisher" || + claim.phase === "requested" || + claim.phase === "rendered" + ) { + return; + } + var delay = Math.max(0, claim.expiresAt - Date.now()); + if (delay > 0) { + window.setTimeout(retry, delay + 1); + return; + } + } + + tag.cmd.push(function () { + if ( + (ts.navGeneration || 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + var fallbackClaim = claimFirstImpressionForTrustedServer(element); + if (!fallbackClaim) return; + var pubads = window.googletag.pubads(); + var existingSlots = pubads.getSlots ? pubads.getSlots() : []; + var gptSlot = + existingSlots.find(function (candidate) { + return candidate.getSlotElementId() === element.id; + }) || null; + var tsOwned = false; + if (!gptSlot) { + gptSlot = runHandoffInternal(function () { + return window.googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + element.id, + ); + }); + if (!gptSlot) { + releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); + return; + } + gptSlot.addService(pubads); + tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[element.id] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: element.id, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + } + + var targeting = bootstrapTargeting(slot, bid); + Object.entries(targeting).forEach(function (entry) { + gptSlot.setTargeting(entry[0], entry[1]); + }); + fallbackClaim.targeting = targeting; + var slotElementId = gptSlot.getSlotElementId() || element.id; + ts.divToSlotId = ts.divToSlotId || {}; + ts.divToSlotId[element.id] = slot.id; + ts.divToSlotId[slotElementId] = slot.id; + if (tsOwned) { + ts.prevGptSlots = ts.prevGptSlots || []; + ts.prevGptSlots.push(gptSlot); + } + if (!ts.servicesEnabled) { + pubads.enableSingleRequest(); + window.googletag.enableServices(); + ts.servicesEnabled = true; + } + if (tsOwned) { + runHandoffInternal(function () { + window.googletag.display(slotElementId); + }); + } + syncInitialLoadDisabled(window.googletag); + if (!tsOwned || ts.gptInitialLoadDisabled) { + ts.adInitRefreshInProgress = true; + try { + runHandoffInternal(function () { + pubads.refresh([gptSlot]); + }); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; + + retry(); + } + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -476,6 +730,14 @@ } var actualDivId = el.id; var b = bids[slot.id] || {}; + var tsClaim = claimFirstImpressionForTrustedServer(el); + if (!tsClaim) { + var currentClaim = firstImpressionClaim(el); + if (currentClaim && currentClaim.owner === "publisher") { + scheduleFirstImpressionFallback(slot, b, el, generation); + } + return; + } var existingSlots = googletag.pubads().getSlots(); var s = @@ -493,7 +755,10 @@ actualDivId, ); }); - if (!s) return; + if (!s) { + releaseTrustedServerFirstImpressionClaim(el, tsClaim); + return; + } s.addService(googletag.pubads()); tsOwned = true; ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; @@ -508,20 +773,11 @@ }; } - Object.entries(slot.targeting || {}).forEach(function (e) { - s.setTargeting(e[0], e[1]); - }); - [ - "hb_pb", - "hb_bidder", - "hb_adid", - "hb_cache_host", - "hb_cache_path", - ].forEach(function (k) { - if (b[k]) s.setTargeting(k, b[k]); + var targeting = bootstrapTargeting(slot, b); + Object.entries(targeting).forEach(function (entry) { + s.setTargeting(entry[0], entry[1]); }); - // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts - s.setTargeting("ts_initial", "1"); + tsClaim.targeting = targeting; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -540,7 +796,9 @@ }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - if (!ts.servicesEnabled) { + var hasRenderableWork = + slotsToDisplay.length > 0 || slotsToRefresh.length > 0; + if (!ts.servicesEnabled && hasRenderableWork) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; diff --git a/crates/trusted-server-integration-tests/browser/package-lock.json b/crates/trusted-server-integration-tests/browser/package-lock.json index 39b512a1d..00f5a6d07 100644 --- a/crates/trusted-server-integration-tests/browser/package-lock.json +++ b/crates/trusted-server-integration-tests/browser/package-lock.json @@ -8,7 +8,18 @@ "name": "integration-tests-browser", "version": "1.0.0", "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "prebid-universal-creative": "1.17.2" + } + }, + "node_modules/@gulpjs/messages": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@gulpjs/messages/-/messages-1.1.0.tgz", + "integrity": "sha512-Ys9sazDatyTgZVb4xPlDufLweJ/Os2uHWOv+Caxvy2O85JcnT4M3vc73bi8pdLWlv3fdWQz3pdI9tVwo8rQQSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" } }, "node_modules/@playwright/test": { @@ -27,6 +38,308 @@ "node": ">=18" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", + "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-slice": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", + "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-plugin-transform-object-assign": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-assign/-/babel-plugin-transform-object-assign-6.22.0.tgz", + "integrity": "sha512-N6Pddn/0vgLjnGr+mS7ttlFkQthqcnINE9EMOxB0CF8F4t6kuJXz6NUeLfSoRbLmkGh0mgDs9i2isdaZj0Ghtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-runtime": "^6.22.0" + } + }, + "node_modules/babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-props": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz", + "integrity": "sha512-bVWtw1wQLzzKiYROtvNlbJgxgBYt2bMJpkCbKmXM3xyijvcjjWXEk5nyrrT3bgJ7ODb19ZohE2T0Y3FgNPyoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "each-props": "^3.0.0", + "is-plain-object": "^5.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "dev": true, + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/each-props": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/each-props/-/each-props-3.0.0.tgz", + "integrity": "sha512-IYf1hpuWrdzse/s/YJOrFmU15lyhSzxelNVAHTEG3DtP4QsLTWZUzcUL3HMXmKQxXpa4EIrBPpwRgj0aehdvAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0", + "object.defaults": "^1.1.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/findup-sync": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", + "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.3", + "micromatch": "^4.0.4", + "resolve-dir": "^1.0.1" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/fined": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-2.0.0.tgz", + "integrity": "sha512-OFRzsL6ZMHz5s0JrsEr+TpdGNCtrVtnuG3x1yzGNiQHT0yaDnXAj8V/lWcpJVrnoDpcwXcASxAZYbuXda2Y82A==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.2", + "is-plain-object": "^5.0.0", + "object.defaults": "^1.1.0", + "object.pick": "^1.3.0", + "parse-filepath": "^1.0.2" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/flagged-respawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-2.0.0.tgz", + "integrity": "sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", + "dev": true, + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -42,6 +355,450 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/glogg": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/glogg/-/glogg-2.2.0.tgz", + "integrity": "sha512-eWv1ds/zAlz+M1ioHsyKJomfY7jbDDPpwSkv14KQj89bycx1nvK5/2Cj/T9g7kzJcX5Bc7Yv22FjfBZS/jl94A==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparkles": "^2.1.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/gulp-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.1.0.tgz", + "integrity": "sha512-zZzwlmEsTfXcxRKiCHsdyjZZnFvXWM4v1NqBJSYbuApkvVKivjcmOS2qruAJ+PkEHLFavcDKH40DPc1+t12a9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@gulpjs/messages": "^1.1.0", + "chalk": "^4.1.2", + "copy-props": "^4.0.0", + "gulplog": "^2.2.0", + "interpret": "^3.1.1", + "liftoff": "^5.0.1", + "mute-stdout": "^2.0.0", + "replace-homedir": "^2.0.0", + "semver-greatest-satisfied-range": "^2.0.0", + "string-width": "^4.2.3", + "v8flags": "^4.0.0", + "yargs": "^16.2.0" + }, + "bin": { + "gulp": "bin/gulp.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gulplog": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-2.2.0.tgz", + "integrity": "sha512-V2FaKiOhpR3DRXZuYdRLn/qiY0yI5XmqbTKrYbdemJ+xOh2d2MOweI/XFgMzd/9+1twdvMwllnZbWZNJ+BOm4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "glogg": "^2.2.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/liftoff": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.1.tgz", + "integrity": "sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "findup-sync": "^5.0.0", + "fined": "^2.0.0", + "flagged-respawn": "^2.0.0", + "is-plain-object": "^5.0.0", + "rechoir": "^0.8.0", + "resolve": "^1.20.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mute-stdout": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-2.0.0.tgz", + "integrity": "sha512-32GSKM3Wyc8dg/p39lWPKYu8zci9mJFzV1Np9Of0ZEpe6Fhssn/FbI7ywAMd40uX+p3ZKh3T5EeCFv81qS3HmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-each": "^1.0.1", + "array-slice": "^1.0.0", + "for-own": "^1.0.0", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-root-regex": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -73,6 +830,312 @@ "engines": { "node": ">=18" } + }, + "node_modules/postscribe": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/postscribe/-/postscribe-2.0.8.tgz", + "integrity": "sha512-Sxt6pek38NKX85Vb/PbcritqVxsgPZQFLcuf4o0f7lXRb76jM0XP79SGwCBPRTuv+U2zqByQan8EzRjqquD73A==", + "dev": true, + "license": "MIT", + "dependencies": { + "prescribe": ">=1.1.2" + } + }, + "node_modules/prebid-universal-creative": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/prebid-universal-creative/-/prebid-universal-creative-1.17.2.tgz", + "integrity": "sha512-+1fB/eD3eXF+m8T0S4GL/wrXatx/tpeTtZ6ptFQnjQxtejiM0GuoFWdJvx5xlqkP1Z14WEE6/eRc1zWcxvg/Dg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "babel-plugin-transform-object-assign": "^6.22.0", + "gulp-cli": "^3.0.0", + "postscribe": "^2.0.8" + } + }, + "node_modules/prescribe": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/prescribe/-/prescribe-1.1.3.tgz", + "integrity": "sha512-HEg0ElY5tmmCshST4tzl47+SirJO2cVo6j/+O4d6xIz+80ixNcN0GgPQsn76AgeTTIAQOrwq1rfoptubQuZ1Uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/replace-homedir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-2.0.0.tgz", + "integrity": "sha512-bgEuQQ/BHW0XkkJtawzrfzHFSN70f/3cNOiHa2QsYxqrjaC30X1k74FJ6xswVBP0sr0SpGIdVFuPwfrYziVeyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-greatest-satisfied-range": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-2.0.0.tgz", + "integrity": "sha512-lH3f6kMbwyANB7HuOWRMlLCa2itaCrZJ+SAqqkSZrZKO/cAsk2EOyaKHUtNkVLFyFW9pct22SFesFp3Z7zpA0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sver": "^1.8.3" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/sparkles": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-2.1.0.tgz", + "integrity": "sha512-r7iW1bDw8R/cFifrD3JnQJX0K1jqT0kprL48BiBpLZLJPmAm34zsVBsK5lc7HirZYZqMW65dOXZgbAGt/I6frg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sver": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/sver/-/sver-1.8.4.tgz", + "integrity": "sha512-71o1zfzyawLfIWBOmw8brleKyvnbn73oVHNCsu51uPMz/HWiKkkXsI31JjHW5zqXEqnPYkIiHd8ZmL7FCimLEA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "semver": "^6.3.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/v8flags": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz", + "integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } } } } diff --git a/crates/trusted-server-integration-tests/browser/package.json b/crates/trusted-server-integration-tests/browser/package.json index 13282f289..42855b5b2 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -8,6 +8,7 @@ "test:wordpress": "TEST_FRAMEWORK=wordpress npx playwright test" }, "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "prebid-universal-creative": "1.17.2" } } 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 fce505d42..927b89a45 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 @@ -10,6 +10,13 @@ const SCRIPT_CREATIVE_URL = "https://creative.example/script.js"; const SANDBOX = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"; const TSJS_CRATE = resolve(__dirname, "../../../../trusted-server-js"); +const PUC_BANNER = readFileSync( + resolve( + __dirname, + "../../node_modules/prebid-universal-creative/dist/banner.js", + ), + "utf8", +); function clientAuctionBundlePaths() { const manifestPath = resolve(TSJS_CRATE, "dist/prebid/manifest.json"); @@ -197,6 +204,145 @@ const SCRIPT_CREATIVE = `(function(){ })();`; test.describe("APS rendering", () => { + test("renders through real PUC and expands only its authenticated 1x1 shell", async ({ + page, + }) => { + const adId = "fictional-inline-ad-id"; + const publisherOrigin = new URL(runtimeUrl("/")).origin; + const outerCreativeUrl = runtimeUrl("/fictional-puc-shell"); + let creativeRequests = 0; + + await page.route(runtimeUrl("/aps-puc-topology-test"), (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: '
', + }), + ); + await page.route(outerCreativeUrl, (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: ``, + }), + ); + await page.route(IFRAME_CREATIVE_URL, (route) => { + creativeRequests += 1; + return route.fulfill({ + status: 200, + contentType: "text/html", + body: IFRAME_CREATIVE, + }); + }); + + await page.goto(runtimeUrl("/aps-puc-topology-test")); + await page.addScriptTag({ path: clientAuctionBundlePaths().gpt }); + await page.evaluate( + ({ creativeUrl, outerUrl, selectedAdId }) => { + const typedWindow = window as unknown as { + tsjs: Record; + pucEvents: Array>; + }; + typedWindow.tsjs = { + bids: { + "aps-slot": { + hb_adid: selectedAdId, + hb_bidder: "fictional", + hb_pb: "1.23", + adm: ``, + w: 300, + h: 250, + }, + }, + adSlots: [ + { + id: "aps-slot", + div_id: "div-aps", + gam_unit_path: "/fictional/aps", + formats: [[300, 250]], + }, + ], + }; + typedWindow.pucEvents = []; + const locator = document.createElement("iframe"); + locator.name = "__pb_locator__"; + document.body.appendChild(locator); + window.addEventListener("message", (event) => { + try { + const message = JSON.parse( + String(event.data), + ) as Record; + if (message.message === "Prebid Event") { + typedWindow.pucEvents.push(message); + } + } catch { + // Ignore unrelated publisher messages. + } + }); + + const slot = document.getElementById("div-aps")!; + slot.style.width = "1px"; + slot.style.height = "1px"; + const frame = document.createElement("iframe"); + frame.id = "google_ads_iframe_fictional_0"; + frame.width = "1"; + frame.height = "1"; + frame.style.width = "1px"; + frame.style.height = "1px"; + frame.src = outerUrl; + slot.appendChild(frame); + + const other = document.getElementById("div-other")!; + const otherFrame = document.createElement("iframe"); + otherFrame.width = "1"; + otherFrame.height = "1"; + otherFrame.style.width = "1px"; + otherFrame.style.height = "1px"; + other.appendChild(otherFrame); + }, + { + creativeUrl: IFRAME_CREATIVE_URL, + outerUrl: outerCreativeUrl, + selectedAdId: adId, + }, + ); + + await expect.poll(() => creativeRequests).toBe(1); + await expect + .poll(() => + page.evaluate(() => + ( + window as unknown as { + pucEvents: Array>; + } + ).pucEvents.some( + (event) => event.event === "adRenderSucceeded", + ), + ), + ) + .toBe(true); + await expect(page.locator("#google_ads_iframe_fictional_0")).toHaveCSS( + "width", + "300px", + ); + await expect(page.locator("#google_ads_iframe_fictional_0")).toHaveCSS( + "height", + "250px", + ); + await expect(page.locator("#div-aps")).toHaveCSS("width", "300px"); + await expect(page.locator("#div-aps")).toHaveCSS("height", "250px"); + await expect(page.locator("#div-other iframe")).toHaveCSS( + "width", + "1px", + ); + await expect(page.locator("#div-other iframe")).toHaveCSS( + "height", + "1px", + ); + }); + test("renders a trustedServer adapter bid using Prebid's generated GAM ad ID", async ({ page, }) => { diff --git a/crates/trusted-server-js/lib/src/core/first_impression.ts b/crates/trusted-server-js/lib/src/core/first_impression.ts new file mode 100644 index 000000000..fc53dad36 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/first_impression.ts @@ -0,0 +1,358 @@ +import type { + FirstImpressionPhase, + FirstImpressionPublisherAuction, + FirstImpressionSlotClaim, + FirstImpressionState, + TsjsApi, +} from './types'; + +/** Time allowed for one navigation's losing first-impression delivery. */ +export const FIRST_IMPRESSION_LEASE_MS = 5000; + +const MAX_FIRST_IMPRESSION_SLOTS = 256; +const MAX_PUBLISHER_AUCTIONS_PER_SLOT = 16; + +function currentGeneration(ts: TsjsApi): number { + return ts.navGeneration ?? 0; +} + +function claimMatchesElement( + claim: FirstImpressionSlotClaim, + element: HTMLElement, + generation: number +): boolean { + return ( + claim.generation === generation && + claim.slotElementId === element.id && + claim.element === element && + element.isConnected + ); +} + +function removePublisherAuction( + state: FirstImpressionState, + claim: FirstImpressionSlotClaim, + token: string, + now: number +): void { + delete claim.publisherAuctions[token]; + if ( + claim.owner === 'publisher' && + (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && + Object.keys(claim.publisherAuctions).length === 0 && + claim.expiresAt <= now + ) { + delete state.slots[claim.slotElementId]; + } +} + +function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressionState { + const generation = currentGeneration(ts); + if (ts.firstImpression?.generation !== generation) { + ts.firstImpression = { generation, nextToken: 0, slots: {}, fallbackSlots: {} }; + } + + const state = ts.firstImpression; + state.slots ??= {}; + state.fallbackSlots ??= {}; + for (const [elementId, claim] of Object.entries(state.slots)) { + if (!claimMatchesElement(claim, claim.element, generation)) { + delete state.slots[elementId]; + continue; + } + for (const [token, auction] of Object.entries(claim.publisherAuctions)) { + if (auction.expiresAt <= now) removePublisherAuction(state, claim, token, now); + } + if ( + claim.owner === 'publisher' && + (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && + Object.keys(claim.publisherAuctions).length === 0 && + claim.expiresAt <= now + ) { + delete state.slots[elementId]; + } + } + for (const [elementId, element] of Object.entries(state.fallbackSlots)) { + if ( + !element.isConnected || + element.id !== elementId || + document.getElementById(elementId) !== element + ) { + delete state.fallbackSlots[elementId]; + } + } + return state; +} + +function activePhysicalElement(element: HTMLElement | null): HTMLElement | undefined { + return element?.isConnected && element.id ? element : undefined; +} + +function visibleThroughAncestors(element: HTMLElement): boolean { + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + const style = window.getComputedStyle(current); + if (style.display === 'none' || style.visibility === 'hidden') return false; + } + return true; +} + +/** Resolve a publisher ad-unit code to one exact active physical slot element. */ +export function resolveFirstImpressionElement(adUnitCode: string): HTMLElement | undefined { + if (!adUnitCode) return undefined; + const exact = activePhysicalElement(document.getElementById(adUnitCode)); + if (exact) return exact; + + const matches = Array.from(document.querySelectorAll('[id]')).filter( + (element) => + element.id.startsWith(adUnitCode) && + !element.id.endsWith('-container') && + visibleThroughAncestors(element) + ); + return matches.length === 1 ? matches[0] : undefined; +} + +/** Return the live ownership claim for an exact slot element. */ +export function firstImpressionClaim( + ts: TsjsApi, + element: HTMLElement +): FirstImpressionSlotClaim | undefined { + const state = pruneFirstImpressionState(ts); + const claim = state.slots[element.id]; + return claim && claimMatchesElement(claim, element, state.generation) ? claim : undefined; +} + +function storeClaim(state: FirstImpressionState, claim: FirstImpressionSlotClaim): boolean { + if ( + !state.slots[claim.slotElementId] && + Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS + ) { + return false; + } + state.slots[claim.slotElementId] = claim; + return true; +} + +/** Atomically claim an untouched slot for Trusted Server. */ +export function claimFirstImpressionForTrustedServer( + ts: TsjsApi, + element: HTMLElement, + now = Date.now() +): FirstImpressionSlotClaim | undefined { + const state = pruneFirstImpressionState(ts, now); + const existing = state.slots[element.id]; + if (existing && claimMatchesElement(existing, element, state.generation)) return undefined; + + const claim: FirstImpressionSlotClaim = { + generation: state.generation, + slotElementId: element.id, + element, + owner: 'trusted_server', + phase: 'delivery_pending', + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + publisherAuctions: {}, + }; + return storeClaim(state, claim) ? claim : undefined; +} + +function schedulePublisherAuctionExpiry(ts: TsjsApi, token: string): void { + window.setTimeout( + () => releasePublisherFirstImpressionAuction(ts, token), + FIRST_IMPRESSION_LEASE_MS + ); +} + +/** Release a TS claim when slot setup failed before any request could start. */ +export function releaseTrustedServerFirstImpressionClaim( + ts: TsjsApi, + element: HTMLElement, + claim: FirstImpressionSlotClaim +): void { + const state = pruneFirstImpressionState(ts); + if ( + state.slots[element.id] === claim && + claim.owner === 'trusted_server' && + claim.phase === 'delivery_pending' && + Object.keys(claim.publisherAuctions).length === 0 + ) { + delete state.slots[element.id]; + } +} + +/** Register real publisher auctions before native `requestBids()` starts. */ +export function registerPublisherFirstImpressionAuctions( + ts: TsjsApi, + adUnitCodes: Iterable, + now = Date.now() +): Map { + const state = pruneFirstImpressionState(ts, now); + const registrations = new Map(); + + for (const adUnitCode of adUnitCodes) { + const element = resolveFirstImpressionElement(adUnitCode); + if (!element) continue; + + let claim = state.slots[element.id]; + if (!claim || !claimMatchesElement(claim, element, state.generation)) { + claim = { + generation: state.generation, + slotElementId: element.id, + element, + owner: 'publisher', + phase: 'auctioning', + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + publisherAuctions: {}, + }; + if (!storeClaim(state, claim)) continue; + } + + if ( + claim.owner === 'publisher' && + (claim.phase === 'requested' || claim.phase === 'rendered') + ) { + continue; + } + if (claim.owner === 'trusted_server' && (claim.suppressionConsumed || claim.expiresAt <= now)) { + continue; + } + if (Object.keys(claim.publisherAuctions).length >= MAX_PUBLISHER_AUCTIONS_PER_SLOT) continue; + + const token = `${state.generation}:${++state.nextToken}`; + const auction: FirstImpressionPublisherAuction = { + token, + adUnitCode, + phase: 'auctioning', + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + adIds: [], + suppressDelivery: claim.owner === 'trusted_server', + }; + claim.publisherAuctions[token] = auction; + if (claim.owner === 'publisher') claim.expiresAt = Math.max(claim.expiresAt, auction.expiresAt); + registrations.set(adUnitCode, token); + schedulePublisherAuctionExpiry(ts, token); + } + + return registrations; +} + +function findPublisherAuction( + ts: TsjsApi, + token: string, + now = Date.now() +): + | { + state: FirstImpressionState; + claim: FirstImpressionSlotClaim; + auction: FirstImpressionPublisherAuction; + } + | undefined { + const state = pruneFirstImpressionState(ts, now); + for (const claim of Object.values(state.slots)) { + const auction = claim.publisherAuctions[token]; + if (auction) return { state, claim, auction }; + } + return undefined; +} + +/** Move one publisher auction to delivery-pending without disturbing overlaps. */ +export function markPublisherFirstImpressionDeliveryPending( + ts: TsjsApi, + token: string, + adIds: string[], + now = Date.now() +): void { + const found = findPublisherAuction(ts, token, now); + if (!found) return; + found.auction.phase = 'delivery_pending'; + found.auction.adIds = [...new Set(adIds)]; + if (found.claim.owner === 'publisher') found.claim.phase = 'delivery_pending'; +} + +/** Release exactly one publisher auction token after failure, timeout, or removal. */ +export function releasePublisherFirstImpressionAuction( + ts: TsjsApi, + token: string, + now = Date.now() +): void { + const found = findPublisherAuction(ts, token, now); + if (!found) return; + found.auction.expiresAt = Math.min(found.auction.expiresAt, now); + if ( + found.claim.owner === 'publisher' && + Object.keys(found.claim.publisherAuctions).length === 1 + ) { + found.claim.expiresAt = now; + } + removePublisherAuction(found.state, found.claim, token, now); +} + +/** Consume one correlated publisher delivery and report whether TS owns it. */ +export function consumePublisherFirstImpressionDelivery( + ts: TsjsApi, + token: string | undefined, + now = Date.now() +): boolean { + if (!token) return false; + 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; + delete found.claim.publisherAuctions[token]; + if (suppress) found.claim.suppressionConsumed = true; + return suppress; +} + +/** Record a GPT request or render, using publisher ownership when no claimant exists. */ +export function observeFirstImpressionGptLifecycle( + ts: TsjsApi, + element: HTMLElement, + phase: Extract, + now = Date.now() +): void { + const state = pruneFirstImpressionState(ts, now); + let claim = state.slots[element.id]; + if (!claim || !claimMatchesElement(claim, element, state.generation)) { + claim = { + generation: state.generation, + slotElementId: element.id, + element, + owner: 'publisher', + phase, + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }; + storeClaim(state, claim); + return; + } + + claim.phase = phase; + if (claim.owner === 'publisher') claim.expiresAt = Number.POSITIVE_INFINITY; +} + +/** Reserve the only Trusted Server fallback allowed for this physical slot and generation. */ +export function reservePublisherFirstImpressionFallback( + ts: TsjsApi, + element: HTMLElement +): boolean { + const state = pruneFirstImpressionState(ts); + const reservedElement = state.fallbackSlots[element.id]; + if (reservedElement) return false; + state.fallbackSlots[element.id] = element; + return true; +} + +/** Delay before an abandoned publisher claim can receive one per-slot TS fallback. */ +export function publisherFirstImpressionRetryDelay( + ts: TsjsApi, + element: HTMLElement, + now = Date.now() +): number | undefined { + const claim = firstImpressionClaim(ts, element); + if (!claim) return 0; + if (claim.owner !== 'publisher') return undefined; + if (claim.phase === 'requested' || claim.phase === 'rendered') return undefined; + return Math.max(0, claim.expiresAt - now); +} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 03ff0aca2..9caaf5b35 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -365,6 +365,40 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } +export type FirstImpressionOwner = 'publisher' | 'trusted_server'; +export type FirstImpressionPhase = 'auctioning' | 'delivery_pending' | 'requested' | 'rendered'; + +/** One publisher auction participating in the current navigation's first impression. */ +export interface FirstImpressionPublisherAuction { + token: string; + adUnitCode: string; + phase: 'auctioning' | 'delivery_pending'; + expiresAt: number; + adIds: string[]; + suppressDelivery: boolean; +} + +/** First-impression ownership for one exact physical slot element. */ +export interface FirstImpressionSlotClaim { + generation: number; + slotElementId: string; + element: HTMLElement; + owner: FirstImpressionOwner; + phase: FirstImpressionPhase; + expiresAt: number; + publisherAuctions: Record; + suppressionConsumed?: boolean; + targeting?: Record; +} + +/** Bounded first-impression state shared by the GPT bootstrap, GPT, and Prebid bundles. */ +export interface FirstImpressionState { + generation: number; + nextToken: number; + slots: Record; + fallbackSlots: Record; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -436,6 +470,10 @@ export interface TsjsApi { gptSlotHandoffs?: Record; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; + /** Per-navigation first-impression ownership shared by GPT and Prebid. */ + firstImpression?: FirstImpressionState; + /** Guards the shared production GPT lifecycle listener installation. */ + firstImpressionListenersInstalled?: boolean; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; /** Internal one-shot state shared by bootstrap and bundle scheduler installs. */ 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 89b480c6f..701f928b2 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,3 +1,11 @@ +import { + claimFirstImpressionForTrustedServer, + firstImpressionClaim, + observeFirstImpressionGptLifecycle, + publisherFirstImpressionRetryDelay, + releaseTrustedServerFirstImpressionClaim, + reservePublisherFirstImpressionFallback, +} from '../../core/first_impression'; import { log } from '../../core/log'; import type { AuctionSlot, @@ -191,48 +199,140 @@ function candidateSlotRoots(elementId: string): HTMLElement[] { return roots; } -function candidateSlotRootsForConfiguredDivId(divId: string): HTMLElement[] { - const roots = candidateSlotRoots(divId); - const dynamicElements = Array.from(document.querySelectorAll('[id]')).filter( - (element) => element.id.startsWith(divId) && !element.id.endsWith('-container') - ); - for (const element of dynamicElements) { - if (!roots.includes(element)) roots.push(element); - const container = document.getElementById(`${element.id}-container`); - if (container && !roots.includes(container)) roots.push(container); - } - return roots; +interface MessageSourceFrame { + iframe: HTMLIFrameElement; + root: HTMLElement; } -function sourceIsInSlotRoots(source: MessageEventSource, roots: HTMLElement[]): boolean { - return roots.some((root) => - Array.from(root.querySelectorAll('iframe')).some((iframe) => iframe.contentWindow === source) - ); +function sourceFrameInRoots( + source: MessageEventSource | null, + roots: readonly HTMLElement[] +): MessageSourceFrame | undefined { + if (!source) return undefined; + const matches = new Map(); + for (const root of roots) { + for (const iframe of root.querySelectorAll('iframe')) { + if (iframe.contentWindow === source && !matches.has(iframe)) matches.set(iframe, root); + } + } + if (matches.size !== 1) return undefined; + const [iframe, root] = matches.entries().next().value as [HTMLIFrameElement, HTMLElement]; + return { iframe, root }; } -function slotIdForMessageSource(source: MessageEventSource | null): string | undefined { - if (!source) return undefined; +function sourceFrameForSlotId( + source: MessageEventSource | null, + slotId: string +): MessageSourceFrame | undefined { + const mappedRoots = Object.entries(window.tsjs?.divToSlotId ?? {}) + .filter(([, mappedSlotId]) => mappedSlotId === slotId) + .flatMap(([elementId]) => candidateSlotRoots(elementId)); + const configuredRoots = (window.tsjs?.adSlots ?? []) + .filter((slot) => slot.id === slotId) + .flatMap((slot) => { + const element = resolveSlotElementByDivId(slot.div_id).element; + return element ? candidateSlotRoots(element.id) : []; + }); + return sourceFrameInRoots(source, [...new Set([...mappedRoots, ...configuredRoots])]); +} - const divToSlotId = window.tsjs?.divToSlotId ?? {}; - const resolvedSlotId = Object.entries(divToSlotId).find(([elementId]) => - sourceIsInSlotRoots(source, candidateSlotRoots(elementId)) - )?.[1]; - if (resolvedSlotId) return resolvedSlotId; +interface MessageSourceSlotFrame extends MessageSourceFrame { + slotId: string; +} - const slots = window.tsjs?.adSlots ?? []; - return [...slots] - .sort((left, right) => right.div_id.length - left.div_id.length) - .find((slot) => sourceIsInSlotRoots(source, candidateSlotRootsForConfiguredDivId(slot.div_id))) - ?.id; +function slotFrameForMessageSource( + source: MessageEventSource | null +): MessageSourceSlotFrame | undefined { + const slotIds = new Set(); + for (const [elementId, slotId] of Object.entries(window.tsjs?.divToSlotId ?? {})) { + if (sourceFrameInRoots(source, candidateSlotRoots(elementId))) slotIds.add(slotId); + } + for (const slot of window.tsjs?.adSlots ?? []) { + const element = resolveSlotElementByDivId(slot.div_id).element; + if (element && sourceFrameInRoots(source, candidateSlotRoots(element.id))) { + slotIds.add(slot.id); + } + } + if (slotIds.size !== 1) return undefined; + const slotId = slotIds.values().next().value as string; + const frame = sourceFrameForSlotId(source, slotId); + return frame ? { ...frame, slotId } : undefined; } -function messageSourceBelongsToAdUnit( +function sourceFrameForAdUnit( source: MessageEventSource | null, adUnitCode: string -): boolean { - return source - ? sourceIsInSlotRoots(source, candidateSlotRootsForConfiguredDivId(adUnitCode)) - : false; +): MessageSourceFrame | undefined { + const element = resolveSlotElementByDivId(adUnitCode).element; + return element ? sourceFrameInRoots(source, candidateSlotRoots(element.id)) : undefined; +} + +function hasCollapsedDimension(element: HTMLElement, dimension: 'width' | 'height'): boolean { + const value = window.getComputedStyle(element)[dimension]; + const match = /^(\d+(?:\.\d+)?)px$/.exec(value); + return match !== null && Number(match[1]) <= 1; +} + +function usesFixedPositioning(element: HTMLElement): boolean { + const position = window.getComputedStyle(element).position; + return position === 'fixed' || position === 'sticky'; +} + +const MAX_CREATIVE_SHELL_DIMENSION = 10_000; + +/** Resize only the authenticated source iframe for a still-current collapsed display shell. */ +function resizeCollapsedCreativeFrame( + source: MessageEventSource | null, + frame: MessageSourceFrame, + width: number, + height: number, + generation: number, + stillOwnsCreative: () => boolean +): void { + if ( + (window.tsjs?.navGeneration ?? 0) !== 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') || + !hasCollapsedDimension(frame.iframe, 'height') || + usesFixedPositioning(frame.iframe) || + frame.iframe.closest( + 'ins[data-anchor-status], [data-google-interstitial], [data-vignette-loaded]' + ) + ) { + return; + } + + const wrapper = frame.iframe.parentElement; + if ( + !wrapper || + wrapper === document.body || + wrapper === document.documentElement || + !frame.root.contains(wrapper) || + usesFixedPositioning(wrapper) + ) { + 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`; + } } function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { @@ -930,11 +1030,176 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { }); } +function installFirstImpressionLifecycleObservers(ts: TsjsApi, g: Partial): void { + if (ts.firstImpressionListenersInstalled) return; + g.cmd?.push(() => { + if (ts.firstImpressionListenersInstalled) return; + const pubads = g.pubads?.(); + if (!pubads?.addEventListener) return; + + const observe = + (phase: 'requested' | 'rendered') => + (event: SlotRenderEndedEvent): void => { + const elementId = event.slot?.getSlotElementId?.(); + const element = elementId ? document.getElementById(elementId) : null; + if (element) observeFirstImpressionGptLifecycle(ts, element, phase); + }; + pubads.addEventListener('slotRequested', observe('requested')); + pubads.addEventListener('slotRenderEnded', observe('rendered')); + ts.firstImpressionListenersInstalled = true; + }); +} + +function trustedServerTargeting( + slot: AuctionSlot, + bid: AuctionBidData +): Record { + const targeting: Record = { ...(slot.targeting ?? {}) }; + for (const key of TS_BID_TARGETING_KEYS) { + if (bid[key]) targeting[key] = String(bid[key]); + } + targeting[TS_INITIAL_TARGETING_KEY] = '1'; + return targeting; +} + +function applyTrustedServerTargeting( + ts: TsjsApi, + gptSlot: GoogleTagSlot, + slot: AuctionSlot, + bid: AuctionBidData, + elementIds: readonly string[] +): string[] { + const previousKeys = ts.prevSlotTargetingKeys ?? {}; + clearTargetingKeys(gptSlot, [ + ...TS_BASE_TARGETING_KEYS, + ...elementIds.flatMap((elementId) => previousKeys[elementId] ?? []), + ]); + const targeting = trustedServerTargeting(slot, bid); + for (const [key, value] of Object.entries(targeting)) gptSlot.setTargeting(key, value); + const element = document.getElementById(elementIds[0]!); + const claim = element ? firstImpressionClaim(ts, element) : undefined; + if (claim?.owner === 'trusted_server') claim.targeting = targeting; + return Object.keys(slot.targeting ?? {}); +} + +function schedulePublisherFirstImpressionFallback( + ts: TsjsApi, + g: Partial, + slot: AuctionSlot, + bid: AuctionBidData, + element: HTMLElement, + generation: number +): void { + if (!reservePublisherFirstImpressionFallback(ts, element)) return; + + const retry = (): void => { + if ( + (ts.navGeneration ?? 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + const delay = publisherFirstImpressionRetryDelay(ts, element); + if (delay === undefined) return; + if (delay > 0) { + window.setTimeout(retry, delay + 1); + return; + } + + g.cmd?.push(() => { + if ( + (ts.navGeneration ?? 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + const claim = claimFirstImpressionForTrustedServer(ts, element); + if (!claim) return; + + const pubads = g.pubads?.(); + if (!pubads) { + releaseTrustedServerFirstImpressionClaim(ts, element, claim); + return; + } + let gptSlot = pubads + .getSlots?.() + .find((candidate) => candidate.getSlotElementId() === element.id); + let tsOwned = false; + if (!gptSlot) { + gptSlot = + withGptSlotHandoffInternal(ts, () => + g.defineSlot?.(slot.gam_unit_path, slot.formats, element.id) + ) ?? undefined; + if (!gptSlot) { + releaseTrustedServerFirstImpressionClaim(ts, element, claim); + return; + } + gptSlot.addService(pubads); + tsOwned = true; + (ts.gptSlotHandoffs ??= {})[element.id] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: element.id, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + } + + const slotElementId = gptSlot.getSlotElementId?.() ?? element.id; + const targetingKeys = applyTrustedServerTargeting(ts, gptSlot, slot, bid, [ + element.id, + slotElementId, + ]); + (ts.divToSlotId ??= {})[element.id] = slot.id; + if (slotElementId !== element.id) ts.divToSlotId[slotElementId] = slot.id; + (ts.prevSlotTargetingKeys ??= {})[element.id] = targetingKeys; + if (slotElementId !== element.id) ts.prevSlotTargetingKeys[slotElementId] = targetingKeys; + if (tsOwned) (ts.prevGptSlots ??= []).push(gptSlot); + + try { + ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + trustedServerOpportunity(bid), + bid.hb_auction_id, + slot.formats + ); + } catch { + // Diagnostics must not alter fallback delivery. + } + + if (!ts.servicesEnabled) { + pubads.enableSingleRequest(); + g.enableServices?.(); + ts.servicesEnabled = true; + } + if (tsOwned) withGptSlotHandoffInternal(ts, () => g.display?.(slotElementId)); + syncInitialLoadDisabled(g, ts); + if (!tsOwned || ts.gptInitialLoadDisabled) { + ts.adInitRefreshInProgress = true; + try { + withGptSlotHandoffInternal(ts, () => pubads.refresh([gptSlot!])); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; + + retry(); +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); installScheduleInitialAdInit(ts); + const g = (window as GptWindow).googletag; + if (g) installFirstImpressionLifecycleObservers(ts, g); installLatePublisherSlotHandoff(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; @@ -951,6 +1216,7 @@ export function installTsAdInit(): void { const generation = ts.navGeneration ?? 0; const g = (window as GptWindow).googletag; if (!g) return; + installFirstImpressionLifecycleObservers(ts, g); const warnedResolutionFailures = new Set(); g.cmd?.push(() => { @@ -1000,6 +1266,8 @@ export function installTsAdInit(): void { (g.pubads!().getSlots?.() ?? []).forEach((gptSlot: GoogleTagSlot) => { const elementId = gptSlot.getSlotElementId(); if (!prevTouchedDivIds.has(elementId)) return; + const element = document.getElementById(elementId); + if (element && firstImpressionClaim(ts, element)) return; clearTargetingKeys(gptSlot, [ ...TS_BASE_TARGETING_KEYS, ...(prevSlotTargetingKeys[elementId] ?? []), @@ -1037,6 +1305,14 @@ export function installTsAdInit(): void { } const actualDivId = el.id; const bid = bids[slot.id] ?? {}; + const firstImpression = claimFirstImpressionForTrustedServer(ts, el); + if (!firstImpression) { + const claim = firstImpressionClaim(ts, el); + if (claim?.owner === 'publisher') { + schedulePublisherFirstImpressionFallback(ts, g, slot, bid, el, generation); + } + return; + } const existingSlot = g.pubads!() .getSlots?.() @@ -1052,7 +1328,10 @@ export function installTsAdInit(): void { const defined = withGptSlotHandoffInternal(ts, () => g.defineSlot?.(slot.gam_unit_path, slot.formats, actualDivId) ); - if (!defined) return; + if (!defined) { + releaseTrustedServerFirstImpressionClaim(ts, el, firstImpression); + return; + } defined.addService(g.pubads!()); gptSlot = defined; tsOwned = true; @@ -1068,17 +1347,10 @@ export function installTsAdInit(): void { } const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; - clearTargetingKeys(gptSlot, [ - ...TS_BASE_TARGETING_KEYS, - ...(prevSlotTargetingKeys[actualDivId] ?? []), - ...(prevSlotTargetingKeys[slotDivId2] ?? []), + const slotTargetingKeys = applyTrustedServerTargeting(ts, gptSlot, slot, bid, [ + actualDivId, + slotDivId2, ]); - - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); - TS_BID_TARGETING_KEYS.forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); - }); - gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); // Diagnostics are observational only. A missing or malformed debug // implementation must never interrupt slot mapping or delivery. try { @@ -1098,7 +1370,6 @@ export function installTsAdInit(): void { // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; if (slotDivId2 !== actualDivId) divToSlotId[slotDivId2] = slot.id; - const slotTargetingKeys = Object.keys(slot.targeting ?? {}); nextSlotTargetingKeys[actualDivId] = slotTargetingKeys; if (slotDivId2 !== actualDivId) nextSlotTargetingKeys[slotDivId2] = slotTargetingKeys; if (tsOwned) { @@ -1398,6 +1669,7 @@ export function installSpaAuctionHook(): void { if (path === currentPath) return; currentPath = path; ts.navGeneration = (ts.navGeneration ?? 0) + 1; + delete ts.firstImpression; // A route change invalidates hydration aliases before the new route's // publisher can define a same-prefix slot while page-bids is in flight. for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs ?? {})) { @@ -1682,6 +1954,7 @@ export function installTsRenderBridge(): void { if (!port) return; const now = Date.now(); + const generation = window.tsjs?.navGeneration ?? 0; pruneConsumedPrebidApsIds(consumedPrebidApsIds, now); const consumedPrebidAps = consumedPrebidApsIds.get(adId); if (consumedPrebidAps) { @@ -1698,7 +1971,8 @@ export function installTsRenderBridge(): void { // Prebid handles ad IDs globally and would otherwise answer a request from // an unrelated iframe when this slot-bound capability rejects it. e.stopImmediatePropagation(); - if (!messageSourceBelongsToAdUnit(e.source, prebidRendererEntry.adUnitCode)) return; + const sourceFrame = sourceFrameForAdUnit(e.source, prebidRendererEntry.adUnitCode); + if (!sourceFrame) return; const renderer = validateApsRenderer(prebidRendererEntry.renderer); if (!renderer || !hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return; if (!consumeApsPrebidRenderer(adId, prebidRendererEntry)) return; @@ -1731,6 +2005,16 @@ export function installTsRenderBridge(): void { height: validatedRenderer.height, }) ); + resizeCollapsedCreativeFrame( + e.source, + sourceFrame, + validatedRenderer.width, + validatedRenderer.height, + generation, + () => + sourceFrameForAdUnit(e.source, prebidRendererEntry.adUnitCode)?.iframe === + sourceFrame.iframe + ); return true; } catch (err) { log.warn(`[tsjs-gpt] APS Prebid response post failed for '${adId}'`, err); @@ -1748,8 +2032,8 @@ export function installTsRenderBridge(): void { return; } - const sourceSlotId = slotIdForMessageSource(e.source); - if (!sourceSlotId) return; + const sourceSlotFrame = slotFrameForMessageSource(e.source); + if (!sourceSlotFrame) return; // Resolve the bid by the requesting slot, not by the first bid whose hb_adid // matches. hb_adid is not unique per bid: absent PBS Cache it falls back to a @@ -1758,7 +2042,7 @@ export function installTsRenderBridge(): void { // first-match-by-adId lookup would resolve every duplicate to one slot, so all // but that slot render blank. const bids = window.tsjs?.bids ?? {}; - const slotId = sourceSlotId; + const slotId = sourceSlotFrame.slotId; const matchedBid = bids[slotId]; // Not a TS bid, or the requesting slot's bid does not own this adId — let @@ -1795,6 +2079,17 @@ export function installTsRenderBridge(): void { height: validatedRenderer.height, }) ); + resizeCollapsedCreativeFrame( + e.source, + sourceSlotFrame, + validatedRenderer.width, + validatedRenderer.height, + generation, + () => + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + ); return true; } catch (err) { log.warn(`[tsjs-gpt] APS server response post failed for '${slotId}'`, err); @@ -1841,6 +2136,13 @@ 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 + ) + ); safelyRecordCreativeResponse(attemptId); fireWinBillingBeacons(slotId, matchedBid); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from inline adm`); @@ -1890,6 +2192,8 @@ export function installTsRenderBridge(): void { cached.price !== undefined ? expandAuctionPriceMacro(cached.adm, cached.price) : cached.adm; + const cachedWidth = cached.width ?? width; + const cachedHeight = cached.height ?? height; try { port.postMessage( JSON.stringify({ @@ -1897,10 +2201,21 @@ export function installTsRenderBridge(): void { adId, ad, renderer: TS_DISPLAY_RENDERER, - width: cached.width ?? width, - height: cached.height ?? height, + width: cachedWidth, + height: cachedHeight, }) ); + resizeCollapsedCreativeFrame( + e.source, + sourceSlotFrame, + cachedWidth, + cachedHeight, + generation, + () => + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + ); } catch (err) { safelyRecordCreativeFailure(attemptId, 'response_post_failed'); log.warn(`[tsjs-gpt] pbRender bridge: response post failed for '${slotId}'`, err); 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 44b47f2da..65cbb0697 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -13,6 +13,13 @@ import type _pbjsDefault from 'prebid.js'; +import { + consumePublisherFirstImpressionDelivery, + firstImpressionClaim, + markPublisherFirstImpressionDeliveryPending, + registerPublisherFirstImpressionAuctions, + releasePublisherFirstImpressionAuction, +} from '../../core/first_impression'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import { registerApsPrebidRenderer, validateApsRenderer } from '../aps/render'; @@ -375,10 +382,13 @@ type PendingPublisherBid = { adUnitCode: string; expiresAt: number; registrationId: number; + firstImpressionToken?: string; }; type PendingPublisherCode = { + adUnitCode: string; expiresAt: number; registrationId: number; + firstImpressionToken?: string; }; type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; type PrebidWithRemoveAdUnit = { @@ -388,8 +398,9 @@ type PrebidWithRemoveAdUnit = { let publisherAdUnitSnapshots = new Map(); let pendingPublisherBids = new Map(); -let pendingPublisherCodes = new Map(); +let pendingPublisherCodes = new Map>(); let pendingPublisherRegistrationId = 0; +let publisherFirstImpressionTokens = new Map>(); let syntheticRefreshAdUnits = new WeakSet(); type TrustedServerBidRequest = { adUnitCode?: string; @@ -414,6 +425,7 @@ type RefreshGptSlot = { getSlotElementId?: () => string; getAdUnitPath?: () => string; getTargeting?: (key: string) => string[]; + setTargeting?: (key: string, value: string | string[]) => RefreshGptSlot; clearTargeting?: (key?: string) => RefreshGptSlot; getSizes?: () => unknown[]; }; @@ -819,26 +831,74 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } +function restoreTrustedServerFirstImpressionTargeting(slot: RefreshGptSlot): void { + const ts = window.tsjs; + const injectedSlot = findInjectedSlotForRefresh(slot); + const element = [refreshSlotElementId(slot), injectedSlot?.div_id] + .filter((elementId): elementId is string => Boolean(elementId)) + .map((elementId) => document.getElementById(elementId)) + .find((candidate): candidate is HTMLElement => + Boolean(candidate && ts && firstImpressionClaim(ts, candidate)?.owner === 'trusted_server') + ); + const claim = ts && element ? firstImpressionClaim(ts, element) : undefined; + if (claim?.owner !== 'trusted_server' || !claim.targeting || !slot.setTargeting) return; + clearRefreshTargeting(slot); + for (const [key, value] of Object.entries(claim.targeting)) slot.setTargeting(key, value); +} + +/** Track a first-impression token until its exact auction is consumed or abandoned. */ +function trackPublisherFirstImpressionToken(adUnitCode: string, token: string): void { + const tokens = publisherFirstImpressionTokens.get(adUnitCode) ?? new Set(); + tokens.add(token); + publisherFirstImpressionTokens.set(adUnitCode, tokens); +} + +function forgetPublisherFirstImpressionToken(adUnitCode: string, token?: string): void { + const tokens = publisherFirstImpressionTokens.get(adUnitCode); + if (!tokens) return; + if (token === undefined) { + if (window.tsjs) { + for (const current of tokens) releasePublisherFirstImpressionAuction(window.tsjs, current); + } + publisherFirstImpressionTokens.delete(adUnitCode); + return; + } + tokens.delete(token); + if (tokens.size === 0) publisherFirstImpressionTokens.delete(adUnitCode); +} + /** Remove pending delivery state for an ad unit, optionally from one registration only. */ function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: number): void { - const pendingCode = pendingPublisherCodes.get(adUnitCode); - if (registrationId !== undefined && pendingCode?.registrationId !== registrationId) return; + const registrations = pendingPublisherCodes.get(adUnitCode); + if (registrations) { + if (registrationId === undefined) { + pendingPublisherCodes.delete(adUnitCode); + } else { + registrations.delete(registrationId); + if (registrations.size === 0) pendingPublisherCodes.delete(adUnitCode); + } + } - pendingPublisherCodes.delete(adUnitCode); for (const [adId, pendingBid] of pendingPublisherBids) { if ( pendingBid.adUnitCode === adUnitCode && (registrationId === undefined || pendingBid.registrationId === registrationId) ) { pendingPublisherBids.delete(adId); + if (pendingBid.firstImpressionToken) { + forgetPublisherFirstImpressionToken(adUnitCode, pendingBid.firstImpressionToken); + } } } } /** Discard delivery state that outlived the publisher auction which created it. */ function prunePendingPublisherBids(now = Date.now()): void { - for (const [adUnitCode, pendingCode] of pendingPublisherCodes) { - if (pendingCode.expiresAt <= now) pendingPublisherCodes.delete(adUnitCode); + for (const [adUnitCode, registrations] of pendingPublisherCodes) { + for (const [registrationId, pendingCode] of registrations) { + if (pendingCode.expiresAt <= now) registrations.delete(registrationId); + } + if (registrations.size === 0) pendingPublisherCodes.delete(adUnitCode); } for (const [adId, pendingBid] of pendingPublisherBids) { @@ -846,12 +906,15 @@ function prunePendingPublisherBids(now = Date.now()): void { } } -/** Store a short-lived pending publisher ad-unit code for delivery correlation. */ -function storePendingPublisherCode(adUnitCode: string, pendingCode: PendingPublisherCode): void { - pendingPublisherCodes.delete(adUnitCode); - pendingPublisherCodes.set(adUnitCode, pendingCode); +/** Store a short-lived pending publisher ad-unit code without erasing overlaps. */ +function storePendingPublisherCode(pendingCode: PendingPublisherCode): void { + const registrations = pendingPublisherCodes.get(pendingCode.adUnitCode) ?? new Map(); + registrations.set(pendingCode.registrationId, pendingCode); + pendingPublisherCodes.set(pendingCode.adUnitCode, registrations); - if (pendingPublisherCodes.size > MAX_PENDING_PUBLISHER_BIDS) { + 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); } @@ -868,29 +931,18 @@ function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid) } } -/** Register every requested publisher code and any bid IDs returned for that auction. */ -function registerPendingPublisherBids( +function publisherResponseAdIds( publisherAdUnitCodes: Set, bidResponses: unknown -): number { - prunePendingPublisherBids(); - const registrationId = ++pendingPublisherRegistrationId; - const expiresAt = Date.now() + PENDING_PUBLISHER_DELIVERY_TTL_MS; - - for (const adUnitCode of publisherAdUnitCodes) { - removePendingPublisherBidsForCode(adUnitCode); - storePendingPublisherCode(adUnitCode, { expiresAt, registrationId }); - } - - if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) { - return registrationId; - } +): Map { + const adIds = new Map(); + if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) + return adIds; for (const [responseCode, responseGroup] of Object.entries(bidResponses)) { if (!responseGroup || typeof responseGroup !== 'object') continue; const bids = (responseGroup as { bids?: unknown }).bids; if (!Array.isArray(bids)) continue; - for (const bid of bids) { if (!bid || typeof bid !== 'object') continue; const response = bid as { adId?: unknown; adUnitCode?: unknown }; @@ -898,29 +950,65 @@ function registerPendingPublisherBids( const adUnitCode = typeof response.adUnitCode === 'string' ? response.adUnitCode : responseCode; if (!adId || !adUnitCode || !publisherAdUnitCodes.has(adUnitCode)) continue; + adIds.set(adUnitCode, [...(adIds.get(adUnitCode) ?? []), adId]); + } + } + return adIds; +} - storePendingPublisherBid(adId, { adUnitCode, expiresAt, registrationId }); +/** Register every requested publisher code and any bid IDs returned for that auction. */ +function registerPendingPublisherBids( + publisherAdUnitCodes: Set, + bidResponses: unknown, + firstImpressionTokens: Map +): number { + prunePendingPublisherBids(); + const registrationId = ++pendingPublisherRegistrationId; + const expiresAt = Date.now() + PENDING_PUBLISHER_DELIVERY_TTL_MS; + const responseAdIds = publisherResponseAdIds(publisherAdUnitCodes, bidResponses); + + for (const adUnitCode of publisherAdUnitCodes) { + const firstImpressionToken = firstImpressionTokens.get(adUnitCode); + storePendingPublisherCode({ + adUnitCode, + expiresAt, + registrationId, + firstImpressionToken, + }); + if (firstImpressionToken && window.tsjs) { + markPublisherFirstImpressionDeliveryPending( + window.tsjs, + firstImpressionToken, + responseAdIds.get(adUnitCode) ?? [] + ); + } + } + + for (const [adUnitCode, adIds] of responseAdIds) { + const firstImpressionToken = firstImpressionTokens.get(adUnitCode); + for (const adId of adIds) { + storePendingPublisherBid(adId, { + adUnitCode, + expiresAt, + registrationId, + firstImpressionToken, + }); } } return registrationId; } -/** - * Partition slots by whether they belong to a pending publisher auction. - * - * A current `hb_adid` is the precise signal. When publishers intentionally - * omit that targeting, a short-lived requested-code match preserves delivery - * for no-bid and custom-targeting auctions. Without an ID, that fallback cannot - * distinguish a delayed delivery from the first independent refresh, so it may - * conservatively suppress one auction before its one-shot state is consumed. - * A non-empty unmatched ID remains independent so stale targeting cannot - * suppress a fresh auction. Every match is consumed once. - */ -function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { +interface PublisherDeliveryPartition { + deliverySlots: Set; + suppressedSlots: Set; +} + +/** Partition correlated publisher deliveries from one losing first-impression delivery. */ +function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): PublisherDeliveryPartition { prunePendingPublisherBids(); const deliverySlots = new Set(); - const deliveredCodes = new Set(); + const suppressedSlots = new Set(); for (const slot of targetSlots) { const adIds = slot.getTargeting?.('hb_adid'); @@ -937,16 +1025,23 @@ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set typeof code === 'string' && code.length > 0) - .find((code) => pendingPublisherCodes.has(code)); - const adUnitCode = pendingBid?.adUnitCode ?? pendingCode; - if (!adUnitCode) continue; - - deliverySlots.add(slot); - deliveredCodes.add(adUnitCode); + .flatMap((code) => [...(pendingPublisherCodes.get(code)?.values() ?? [])]) + .sort((left, right) => left.registrationId - right.registrationId)[0]; + const pending = pendingBid ?? pendingCode; + if (!pending) continue; + + const suppress = + pending.firstImpressionToken && window.tsjs + ? consumePublisherFirstImpressionDelivery(window.tsjs, pending.firstImpressionToken) + : false; + if (pending.firstImpressionToken) { + forgetPublisherFirstImpressionToken(pending.adUnitCode, pending.firstImpressionToken); + } + removePendingPublisherBidsForCode(pending.adUnitCode); + (suppress ? suppressedSlots : deliverySlots).add(slot); } - deliveredCodes.forEach((adUnitCode) => removePendingPublisherBidsForCode(adUnitCode)); - return deliverySlots; + return { deliverySlots, suppressedSlots }; } /** Evict publisher state after Prebid removes one or more ad units. */ @@ -955,6 +1050,9 @@ function removePublisherState(adUnitCode?: string | string[]): void { publisherAdUnitSnapshots.clear(); pendingPublisherBids.clear(); pendingPublisherCodes.clear(); + for (const code of publisherFirstImpressionTokens.keys()) { + forgetPublisherFirstImpressionToken(code); + } return; } @@ -962,6 +1060,7 @@ function removePublisherState(adUnitCode?: string | string[]): void { for (const code of adUnitCodes) { publisherAdUnitSnapshots.delete(code); removePendingPublisherBidsForCode(code); + forgetPublisherFirstImpressionToken(code); } } @@ -1084,6 +1183,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs pendingPublisherBids = new Map(); pendingPublisherCodes = new Map(); pendingPublisherRegistrationId = 0; + publisherFirstImpressionTokens = new Map(); syntheticRefreshAdUnits = new WeakSet(); const prebidWithRemoveAdUnit = pbjs as unknown as PrebidWithRemoveAdUnit; @@ -1185,6 +1285,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs .map((unit) => unit.code) .filter((code): code is string => typeof code === 'string' && code.length > 0) ); + const firstImpressionTokens = + !isSyntheticRefresh && !window.tsjs?.adInitRefreshInProgress + ? registerPublisherFirstImpressionAuctions( + (window.tsjs ??= {} as TsjsApi), + publisherAdUnitCodes + ) + : new Map(); + for (const [adUnitCode, token] of firstImpressionTokens) { + trackPublisherFirstImpressionToken(adUnitCode, token); + window.setTimeout( + () => forgetPublisherFirstImpressionToken(adUnitCode, token), + PENDING_PUBLISHER_DELIVERY_TTL_MS + ); + } // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { @@ -1280,7 +1394,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs syncPrebidEidsCookie(); const registrationId = isSyntheticRefresh ? undefined - : registerPendingPublisherBids(publisherAdUnitCodes, args[0]); + : registerPendingPublisherBids(publisherAdUnitCodes, args[0], firstImpressionTokens); if (typeof originalBidsBack !== 'function') return; try { @@ -1291,11 +1405,23 @@ export function installPrebidNpm(config?: Partial): typeof pbjs removePendingPublisherBidsForCode(code, registrationId) ); } + for (const [adUnitCode, token] of firstImpressionTokens) { + releasePublisherFirstImpressionAuction(window.tsjs!, token); + forgetPublisherFirstImpressionToken(adUnitCode, token); + } throw error; } }; - return originalRequestBids(opts); + try { + return originalRequestBids(opts); + } catch (error) { + for (const [adUnitCode, token] of firstImpressionTokens) { + releasePublisherFirstImpressionAuction(window.tsjs!, token); + forgetPublisherFirstImpressionToken(adUnitCode, token); + } + throw error; + } }; // Apply initial configuration @@ -1403,11 +1529,15 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - const deliverySlots = publisherDeliverySlots(targetSlots); - const independentSlots = targetSlots.filter((slot) => !deliverySlots.has(slot)); + const { deliverySlots, suppressedSlots } = publisherDeliverySlots(targetSlots); + suppressedSlots.forEach(restoreTrustedServerFirstImpressionTargeting); + 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) { - recordPrebidRefreshForDiagnostics(targetSlots); - return dispatchPrebidRefresh(originalRefresh, slots, opts); + recordPrebidRefreshForDiagnostics(remainingSlots); + return dispatchPrebidRefresh(originalRefresh, forwardedSlots, opts); } // Clear stale Trusted Server/Prebid targeting from independent slots before @@ -1484,12 +1614,11 @@ export function installRefreshHandler(timeoutMs = 1500): void { log.error('[tsjs-prebid] refresh targeting failed', error); } } - recordPrebidRefreshForDiagnostics(targetSlots); - // Preserve the publisher's original refresh form. In particular, a bare - // GPT refresh remains bare so GPT resolves its registered slot set when - // the auction completes; the dispatch wrapper only scopes the shared - // diagnostics context around the delegated call. - dispatchPrebidRefresh(originalRefresh, slots, opts); + recordPrebidRefreshForDiagnostics(remainingSlots); + // 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); } 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 b7186518b..70a75140e 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 @@ -6,6 +6,7 @@ import { resolve } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import envelope from '../../fixtures/aps-renderer-v1.json'; +import { registerPublisherFirstImpressionAuctions } from '../../../src/core/first_impression'; import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; import { APS_PREBID_CREATIVE_RUNNER_URL, @@ -248,6 +249,7 @@ describe('installTsAdInit', () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; @@ -282,6 +284,105 @@ describe('installTsAdInit', () => { return { mockPubads, mockSlot }; } + it('leaves a publisher-auctioned slot untouched when delayed adInit receives no candidate', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + undefined, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ts.adInit!(); + + expect(mockSlot.setTargeting).not.toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); + expect(ts.divToSlotId).toEqual({}); + expect(ts.prevSlotTargetingKeys).toEqual({}); + }); + + it('falls back once when a publisher auction abandons its first-impression claim', async () => { + vi.useFakeTimers(); + try { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '1.10', hb_adid: 'example-fallback-ad', adm: '
Fallback
' }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ts.adInit!(); + + expect(mockPubads.refresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(5001); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(10_000); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not clear targeting or request again after TS claims an existing slot', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + undefined, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + ts.adInit!(); + const clearCalls = mockSlot.clearTargeting.mock.calls.length; + const targetingCalls = mockSlot.setTargeting.mock.calls.length; + ts.adInit!(); + + expect(mockSlot.clearTargeting).toHaveBeenCalledTimes(clearCalls); + expect(mockSlot.setTargeting).toHaveBeenCalledTimes(targetingCalls); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); + }); + + it.each(['slotRequested', 'slotRenderEnded'] as const)( + 'leaves a publisher slot untouched after an earlier %s event', + async (eventName) => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '2.00', hb_adid: 'late-page-bid', adm: '
Late
' }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const lifecycleListener = mockPubads.addEventListener.mock.calls.find( + ([registeredEvent]) => registeredEvent === eventName + )?.[1] as ((event: SlotRenderEvent) => void) | undefined; + expect(lifecycleListener).toBeDefined(); + lifecycleListener!({ isEmpty: false, slot: mockSlot }); + + ts.adInit!(); + + expect(mockSlot.setTargeting).not.toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); + expect(ts.firstImpression?.slots['div-atf-sidebar']?.owner).toBe('publisher'); + expect(ts.firstImpression?.slots['div-atf-sidebar']?.phase).toBe( + eventName === 'slotRequested' ? 'requested' : 'rendered' + ); + } + ); + it.each([ [ 'inline markup', @@ -1858,7 +1959,9 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); + // The slot already spent its first impression above. Changing GPT's + // initial-load mode must not make a repeated adInit request it again. + expect(nativeRefresh).not.toHaveBeenCalled(); nativeRefresh.mockClear(); gpt.setConfig({ disableInitialLoad: false }); @@ -1877,7 +1980,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).not.toHaveBeenCalled(); // A later modern call can re-enable initial load after the legacy API. nativeRefresh.mockClear(); @@ -3034,6 +3137,23 @@ describe('installTsRenderBridge', () => { return iframe.contentWindow!; } + function createCollapsedTrustedSlotIframe(divId = 'div-header') { + const slot = document.createElement('div'); + slot.id = divId; + const wrapper = document.createElement('div'); + wrapper.style.width = '1px'; + wrapper.style.height = '1px'; + const iframe = document.createElement('iframe'); + iframe.width = '1'; + iframe.height = '1'; + iframe.style.width = '1px'; + iframe.style.height = '1px'; + wrapper.appendChild(iframe); + slot.appendChild(wrapper); + document.body.appendChild(slot); + return { iframe, slot, source: iframe.contentWindow!, wrapper }; + } + async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { let bridgeListener: ((e: MessageEvent) => unknown) | undefined; const origAdd = window.addEventListener.bind(window); @@ -3095,6 +3215,69 @@ describe('installTsRenderBridge', () => { expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); }); + it('expands an authenticated collapsed inline creative shell after response delivery', 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 postMessage = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage }], + source: collapsed.source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(postMessage).toHaveBeenCalledOnce(); + expect(collapsed.iframe.width).toBe('728'); + expect(collapsed.iframe.height).toBe('90'); + expect(collapsed.wrapper.style.width).toBe('728px'); + expect(collapsed.wrapper.style.height).toBe('90px'); + }); + + it.each(['fixed', 'anchor', 'expanded', 'oversized'] as const)( + 'does not resize a %s Universal Creative shell', + async (guard) => { + const tsjs = (window as TestWindow).tsjs!; + tsjs.bids.homepage_header.adm = '
Fictional creative
'; + tsjs.bids.homepage_header.w = guard === 'oversized' ? 10_001 : 300; + tsjs.bids.homepage_header.h = 250; + delete tsjs.bids.homepage_header.nurl; + delete tsjs.bids.homepage_header.burl; + const bridgeListener = await captureBridgeListener(); + const collapsed = createCollapsedTrustedSlotIframe(); + if (guard === 'fixed') collapsed.iframe.style.position = 'fixed'; + if (guard === 'expanded') collapsed.iframe.style.width = '300px'; + if (guard === 'anchor') { + const anchor = document.createElement('ins'); + anchor.dataset.anchorStatus = 'displayed'; + collapsed.slot.insertBefore(anchor, collapsed.wrapper); + anchor.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.source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(collapsed.iframe.width).toBe('1'); + expect(collapsed.iframe.height).toBe('1'); + expect(collapsed.wrapper.style.width).toBe('1px'); + expect(collapsed.wrapper.style.height).toBe('1px'); + } + ); + it('records no creative evidence for an ad ID the requesting slot does not own', async () => { const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(42); const recordTrustedServerCreativeResponse = vi.fn(); @@ -3195,7 +3378,7 @@ describe('installTsRenderBridge', () => { expect(fetchStub).not.toHaveBeenCalled(); }); - it('records response_post_failed when posting inline markup throws', async () => { + it('records response_post_failed without resizing when posting inline markup throws', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(46); const recordTrustedServerCreativeResponse = vi.fn(); @@ -3209,7 +3392,7 @@ describe('installTsRenderBridge', () => { tsjs.bids.homepage_header.adm = '
Creative
'; const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); const stopImmediatePropagation = vi.fn(); expect(() => bridgeListener( @@ -3222,7 +3405,7 @@ describe('installTsRenderBridge', () => { }), }, ], - source, + source: collapsed.source, stopImmediatePropagation, }) as unknown as MessageEvent ) @@ -3232,6 +3415,8 @@ describe('installTsRenderBridge', () => { expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(46, 'response_post_failed'); expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); + expect(collapsed.iframe.width).toBe('1'); + expect(collapsed.iframe.height).toBe('1'); expect(beaconSpy).not.toHaveBeenCalled(); beaconSpy.mockRestore(); }); @@ -3252,7 +3437,8 @@ describe('installTsRenderBridge', () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); + const source = collapsed.source; const stopSpy = vi.fn(); const portMessages: string[] = []; const fakePort = { postMessage: (message: string) => portMessages.push(message) }; @@ -3297,6 +3483,10 @@ describe('installTsRenderBridge', () => { }); expect(String(response.renderer)).not.toContain(renderer.accountId); expect(String(response.renderer)).not.toContain(renderer.aaxResponse); + expect(collapsed.iframe.width).toBe('300'); + expect(collapsed.iframe.height).toBe('250'); + expect(collapsed.wrapper.style.width).toBe('300px'); + expect(collapsed.wrapper.style.height).toBe('250px'); // Universal Creative's dynamic-renderer path evaluates the returned static // source and calls window.render(response, helper, targetWindow). Consume @@ -3417,7 +3607,8 @@ describe('installTsRenderBridge', () => { }; const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); + const source = collapsed.source; const stopSpy = vi.fn(); const portMessages: string[] = []; const event = Object.assign(new Event('message'), { @@ -3453,6 +3644,10 @@ describe('installTsRenderBridge', () => { ); expect(renderer.bidId).not.toBe(prebidAdId); expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + expect(collapsed.iframe.width).toBe('300'); + expect(collapsed.iframe.height).toBe('250'); + expect(collapsed.wrapper.style.width).toBe('300px'); + expect(collapsed.wrapper.style.height).toBe('250px'); expect(fetchStub).not.toHaveBeenCalled(); foreignIframe.remove(); }); @@ -3542,7 +3737,7 @@ describe('installTsRenderBridge', () => { } }); - it('uses the requesting frame to resolve a registered APS dynamic slot prefix', async () => { + it('does not use the requesting frame to disambiguate a registered APS slot prefix', async () => { const renderer = apsRenderer(); const prebidAdId = 'native-dynamic-prebid-ad-id'; const markUsed = vi.fn(); @@ -3556,7 +3751,7 @@ describe('installTsRenderBridge', () => { }, }; const marker = enablePublisherNativeMode(); - const firstSource = createTrustedSlotIframe('div-native-first'); + createTrustedSlotIframe('div-native-first'); const source = createTrustedSlotIframe('div-native-second'); try { @@ -3569,18 +3764,10 @@ describe('installTsRenderBridge', () => { stopImmediatePropagation: vi.fn(), }) as unknown as MessageEvent ); - const native = nativeRunnerIn('div-native-second'); - native.runner.dispatchEvent(new Event('load')); - await Promise.resolve(); - await Promise.resolve(); - expect(native.frame.style.display).toBe(''); - expect(markUsed).toHaveBeenCalledOnce(); - expect( - Array.from(document.querySelectorAll('#div-native-first iframe')).some( - (frame) => frame.contentWindow === firstSource - ) - ).toBe(true); + expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); + expect(markUsed).not.toHaveBeenCalled(); + expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); } finally { marker.remove(); document.getElementById('div-native-first')?.remove(); @@ -4409,7 +4596,7 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); - it('sizes a PBS Cache render from the cached bid dimensions', async () => { + it('sizes a PBS Cache render and its collapsed shell from cached bid dimensions', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); // Cached bid is 300x250 while the slot's first format is 728x90 (from the // default setup). The response must use the cached dimensions. @@ -4421,13 +4608,13 @@ describe('installTsRenderBridge', () => { const bridgeListener = await captureBridgeListener(); const portMessages: string[] = []; const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); bridgeListener( Object.assign(new Event('message'), { data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), ports: [fakePort], - source, + source: collapsed.source, stopImmediatePropagation: vi.fn(), }) as unknown as MessageEvent ); @@ -4438,9 +4625,45 @@ describe('installTsRenderBridge', () => { const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; expect(parsed.width).toBe(300); expect(parsed.height).toBe(250); + expect(collapsed.iframe.width).toBe('300'); + expect(collapsed.iframe.height).toBe('250'); + expect(collapsed.wrapper.style.width).toBe('300px'); + expect(collapsed.wrapper.style.height).toBe('250px'); beaconSpy.mockRestore(); }); + it('does not resize a stale cache response after navigation', async () => { + let resolveText: ((body: string) => void) | undefined; + fetchStub.mockResolvedValue({ + ok: true, + text: () => + new Promise((resolve) => { + resolveText = resolve; + }), + } as Response); + const bridgeListener = await captureBridgeListener(); + const collapsed = createCollapsedTrustedSlotIframe(); + const postMessage = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage }], + source: collapsed.source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + await Promise.resolve(); + expect(resolveText).toBeDefined(); + (window as TestWindow).tsjs!.navGeneration = 1; + resolveText?.(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(postMessage).toHaveBeenCalledOnce(); + expect(collapsed.iframe.width).toBe('1'); + expect(collapsed.iframe.height).toBe('1'); + }); + it('expands ${AUCTION_PRICE} from the cached bid price before responding', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); fetchStub.mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index ab6d646f2..a9c84cc61 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -432,6 +432,65 @@ describe('gpt_bootstrap.js fallback', () => { expect(ts.servicesEnabled).toBe(true); }); + it('fallback adInit leaves a publisher-rendered slot untouched', () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + }; + const mockPubads = { + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + }; + const nativeRefresh = mockPubads.refresh; + const defineSlot = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display: vi.fn(), + }; + document.body.innerHTML = '
'; + runBootstrap(); + const ts = (window as TestWindow).tsjs!; + const element = document.getElementById('div-atf-sidebar')!; + ts.firstImpression = { + generation: 0, + nextToken: 0, + fallbackSlots: {}, + slots: { + 'div-atf-sidebar': { + generation: 0, + slotElementId: 'div-atf-sidebar', + element, + owner: 'publisher', + phase: 'rendered', + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }, + }, + }; + ts.adSlots = [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + }, + ]; + ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; + + ts.adInit!(); + + expect(mockSlot.setTargeting).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); + expect(defineSlot).not.toHaveBeenCalled(); + expect(ts.servicesEnabled).not.toBe(true); + }); + it('fallback adInit cancels queued work when the generation advances before the queue drains', () => { const commandQueue: Array<() => void> = []; const nativeRefresh = vi.fn(); 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 8ead01aa8..7b115c925 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,9 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import { claimFirstImpressionForTrustedServer } 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'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; import envelope from '../../fixtures/aps-renderer-v1.json'; @@ -2560,6 +2562,60 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return recordPrebidRefresh; } + it('suppresses one publisher delivery after TS claims first and allows a later refresh', () => { + const code = 'example-ts-first-slot'; + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + try { + const targeting = new Map([ + ['ts_initial', '1'], + ['hb_adid', 'example-ts-ad-id'], + ['hb_pb', '1.25'], + ]); + const slot = { + getSlotElementId: () => code, + getTargeting: (key: string) => { + const value = targeting.get(key); + return value === undefined ? [] : Array.isArray(value) ? value : [value]; + }, + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, value); + return slot; + }), + clearTargeting: vi.fn((key: string) => { + targeting.delete(key); + return slot; + }), + getSizes: () => [[300, 250]], + }; + const ts = (testWindow.tsjs = {} as TsjsApi) as TsjsApi; + const claim = claimFirstImpressionForTrustedServer(ts, element)!; + claim.targeting = Object.fromEntries(targeting); + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot], { changeCorrelator: false }), + } as unknown as RequestBidsArg); + + 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); + + pubads.refresh([slot], { changeCorrelator: false }); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); + } finally { + element.remove(); + } + }); + it('records a publisher delivery refresh immediately before its GPT request', () => { const slot = { getSlotElementId: () => 'example-delivery-marker', diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index bde759c45..cb1e8eee6 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -195,7 +195,9 @@ In `trusted_server` mode, the TSJS auction client validates the typed renderer d ### GAM and Universal Creative -For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid` and validates the complete envelope. In `trusted_server` mode it returns a static dynamic-renderer program that creates the same opaque renderer iframe. In `publisher_native` mode it instead resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response. +For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid` and validates the complete envelope. In `trusted_server` mode it returns a static dynamic-renderer program that creates the same opaque renderer iframe. After the response is delivered, the bridge expands an authenticated ordinary display iframe only when its width and height attributes and computed geometry are still 1x1. It resizes that source iframe and its immediate collapsed shell parent to the validated winning dimensions. Ambiguous sources, stale navigation or refresh completions, anchors, interstitials, fixed or sticky frames, invalid dimensions, and already-expanded frames remain unchanged. The same guard applies to APS capabilities, inline `adm`, and PBS Cache responses. + +In `publisher_native` mode the bridge instead resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response. That renderer replaces the slot through a different owner and does not run the collapsed-shell helper. After the native runner loads, Trusted Server replaces the existing children of the resolved publisher div with the friendly frame. This removes the GAM or Universal Creative iframe when it is inside that div. If the runner fails, the existing iframe remains, but its Universal Creative request receives no response because Trusted Server has already claimed the selected bid. This one-owner behavior avoids a second render path, but GAM impression and viewability reporting must be validated with the APS account team for the controlled cohort. diff --git a/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md index 8617ef877..147323675 100644 --- a/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md +++ b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md @@ -68,10 +68,14 @@ across every navigation in the user's clickstream rather than once per session. pipeline. The GAM call (`securepubads.g.doubleclick.net`) moving server-side is aspirational, contingent on Google agreement, and is not committed for any phase (see §9.6). -- Eliminating Prebid entirely — a stripped-down Prebid bundle (_slim-Prebid_) is +- Eliminating Prebid entirely. A stripped-down Prebid bundle (_slim-Prebid_) is lazy-loaded post-`window.load` to handle scroll/refresh auctions and userID - enrichment. **TS owns the first impression; Prebid owns subsequent refresh - auctions.** + enrichment. **The first valid claimant owns each navigation's first impression.** + A publisher auction, GPT request, or GPT render consumes the claim before late + page-bids data can target or refresh that slot. If TS claims first, it suppresses + one correlated losing publisher delivery during a bounded lease. Later publisher + refresh auctions proceed normally. Strict TS-first delivery would require holding + publisher delivery and remains a separate design choice. - Dynamic slot discovery (reading the DOM) — this design commits to pre-defined, URL-matched slot templates. Smart Slots' dynamic injection behavior is replaced by server knowledge. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 68e1cf75e..ff5dd3a8b 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -21,9 +21,10 @@ A fix must keep both implementations in sync. 1. A configured placement has at most one initial GPT slot and ad request when TS runs before a publisher defines its inner div. -2. Apply TS targeting and the `ts_initial=1` marker before that single initial - request. -3. Continue reusing a slot that the publisher has already defined. +2. Apply TS targeting and the `ts_initial=1` marker only when TS owns that single + initial request. +3. Continue reusing a slot that the publisher has already defined without changing + its targeting after a publisher auction, GPT request, or GPT render claims it. 4. Keep the TS-only fallback: if the publisher never defines the placement, TS still displays it and makes exactly one initial request. 5. Preserve `disableInitialLoad()`, SPA targeting cleanup, and the rule that TS does @@ -35,11 +36,33 @@ A fix must keep both implementations in sync. - Deduplicating by GAM ad-unit path. Multiple visible placements may validly share a path. - Changing publisher GAM configuration, line items, or refresh policy. -- Delaying the initial TS request while waiting an arbitrary amount of time for - framework hydration. A time-based grace period cannot distinguish a slow - publisher-owned slot from a placement that the publisher will never define. +- Delaying the initial TS request while waiting for a publisher that has not made a + concrete claim. A time-based grace period cannot distinguish a slow publisher-owned + slot from a placement that the publisher will never define. An actual publisher + `requestBids()` call receives a bounded lease instead. - General interception of unrelated GPT slots. +## Decision: first claimant owns delivery + +The first valid claimant owns each physical slot's first impression for the current +navigation. A real publisher `requestBids()` call claims before native Prebid starts. +A GPT `slotRequested` or `slotRenderEnded` event also claims for the publisher when TS +has not claimed first. `adInit()` may write `ts_initial=1`, apply `hb_*` targeting, and +request an existing slot only after it atomically claims an untouched slot. + +Publisher auction claims use unique, expiring registration tokens. The matching +callback moves only its token to delivery-pending and attaches returned ad IDs. +Overlapping auctions cannot clear each other's tokens. If TS claimed first, the GPT +refresh wrapper filters one correlated losing publisher delivery and restores the TS +targeting snapshot. It forwards every unaffected slot and the original refresh options +exactly once. The one-shot state is then consumed, so later publisher refresh auctions +remain eligible. + +If a publisher claim expires without a GPT request, `adInit()` retries only that slot +after checking the navigation generation, DOM element identity, and ownership again. +It never reruns whole-page initialization. Strict TS-first delivery is outside this +design because it would require holding publisher delivery while page-bids settles. + ## Decision: one inner-div slot with late-definition handoff TS will define its fallback slot on the **actual inner div**, never on its outer From efcd1249200d22fc26a691754d4fa6e003fe7a45 Mon Sep 17 00:00:00 2001 From: prk-Jr <49094961+prk-Jr@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:57:09 +0530 Subject: [PATCH 2/3] Harden PR 1079 first-impression arbitration (#1083) * docs: plan PR 1079 review remediation * fix(js): scope first impression delivery ownership * fix(js): reject stale creatives and expand nested shells * Prevent delayed publisher refresh overwrites --- .../browser/tests/shared/aps-renderer.spec.ts | 28 +- .../lib/src/core/first_impression.ts | 45 +- .../trusted-server-js/lib/src/core/types.ts | 3 +- .../lib/src/integrations/gpt/index.ts | 129 ++-- .../lib/src/integrations/prebid/index.ts | 247 +++++++- .../lib/test/integrations/gpt/ad_init.test.ts | 44 +- .../test/integrations/prebid/index.test.ts | 597 +++++++++++++++++- .../test/prebid-artifact-integration.test.mjs | 2 +- .../2026-08-27-pr-1079-review-remediation.md | 154 +++++ ...08-27-pr-1079-review-remediation-design.md | 75 +++ 10 files changed, 1244 insertions(+), 80 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-27-pr-1079-review-remediation.md create mode 100644 docs/superpowers/specs/2026-08-27-pr-1079-review-remediation-design.md 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. From 2a79e6a1c62df3db234dd04248bce23d2e09c1c2 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 27 Aug 2026 14:03:57 -0500 Subject: [PATCH 3/3] Address first-impression arbitration review feedback --- .../src/integrations/gpt_bootstrap.js | 77 ++++- .../lib/src/core/first_impression.ts | 73 +++-- .../lib/src/core/slot_element.ts | 83 ++++++ .../lib/src/integrations/aps/render.ts | 16 +- .../lib/src/integrations/gpt/index.ts | 171 ++++------- .../lib/src/integrations/prebid/index.ts | 56 +++- .../lib/test/integrations/gpt/ad_init.test.ts | 104 ++++++- .../integrations/gpt/gpt_bootstrap.test.ts | 278 +++++++++++++++++- .../lib/test/integrations/gpt/index.test.ts | 2 +- .../test/integrations/gpt/spa_hook.test.ts | 106 ++++++- .../test/integrations/prebid/index.test.ts | 266 ++++++++++++++++- docs/guide/integrations/aps.md | 2 +- ...vent-duplicate-gpt-slot-requests-design.md | 13 +- ...08-27-pr-1079-review-remediation-design.md | 15 +- 14 files changed, 1049 insertions(+), 213 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/slot_element.ts diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 883848509..c7cceaa80 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -103,6 +103,7 @@ }); var FIRST_IMPRESSION_LEASE_MS = 5000; + var MAX_FIRST_IMPRESSION_SLOTS = 256; function firstImpressionState(now) { var generation = ts.navGeneration || 0; @@ -125,14 +126,24 @@ if ( claim.generation !== generation || claim.slotElementId !== elementId || + claim.element.ownerDocument !== document || claim.element !== document.getElementById(elementId) || !claim.element.isConnected ) { delete state.slots[elementId]; return; } + var hasReservedFallback = + claim.owner === "publisher" && + (claim.phase === "auctioning" || claim.phase === "delivery_pending") && + state.fallbackSlots[elementId] === claim.element; Object.keys(claim.publisherAuctions || {}).forEach(function (token) { - if (claim.publisherAuctions[token].expiresAt <= now) { + var auction = claim.publisherAuctions[token]; + if ( + auction.expiresAt <= now && + !hasReservedFallback && + !(claim.owner === "trusted_server" && auction.suppressDelivery) + ) { delete claim.publisherAuctions[token]; } }); @@ -140,7 +151,8 @@ claim.owner === "publisher" && (claim.phase === "auctioning" || claim.phase === "delivery_pending") && Object.keys(claim.publisherAuctions || {}).length === 0 && - claim.expiresAt <= now + claim.expiresAt <= now && + !hasReservedFallback ) { delete state.slots[elementId]; } @@ -162,10 +174,37 @@ return firstImpressionState(Date.now()).slots[element.id]; } + function storeFirstImpressionClaim(state, claim) { + if ( + !state.slots[claim.slotElementId] && + Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS + ) { + return false; + } + state.slots[claim.slotElementId] = claim; + return true; + } + function claimFirstImpressionForTrustedServer(element) { var now = Date.now(); var state = firstImpressionState(now); - if (state.slots[element.id]) return null; + var existing = state.slots[element.id]; + if (existing) { + var canTransitionPublisherFallback = + existing.owner === "publisher" && + existing.phase !== "requested" && + existing.phase !== "rendered" && + existing.expiresAt <= now && + state.fallbackSlots[element.id] === element; + if (!canTransitionPublisherFallback) return null; + existing.owner = "trusted_server"; + existing.phase = "delivery_pending"; + existing.expiresAt = now + FIRST_IMPRESSION_LEASE_MS; + Object.keys(existing.publisherAuctions || {}).forEach(function (token) { + existing.publisherAuctions[token].suppressDelivery = true; + }); + return existing; + } var claim = { generation: state.generation, slotElementId: element.id, @@ -175,8 +214,7 @@ expiresAt: now + FIRST_IMPRESSION_LEASE_MS, publisherAuctions: {}, }; - state.slots[element.id] = claim; - return claim; + return storeFirstImpressionClaim(state, claim) ? claim : null; } function releaseTrustedServerFirstImpressionClaim(element, claim) { @@ -184,10 +222,12 @@ if ( state.slots[element.id] === claim && claim.owner === "trusted_server" && - claim.phase === "delivery_pending" && - Object.keys(claim.publisherAuctions || {}).length === 0 + claim.phase === "delivery_pending" ) { delete state.slots[element.id]; + if (state.fallbackSlots[element.id] === element) { + delete state.fallbackSlots[element.id]; + } } } @@ -208,7 +248,7 @@ var state = firstImpressionState(Date.now()); var claim = state.slots[elementId]; if (!claim) { - claim = state.slots[elementId] = { + storeFirstImpressionClaim(state, { generation: state.generation, slotElementId: elementId, element: element, @@ -216,12 +256,14 @@ phase: phase, expiresAt: Number.POSITIVE_INFINITY, publisherAuctions: {}, - }; + }); + return; + } + claim.phase = phase; + if (claim.owner === "publisher") { + claim.expiresAt = Number.POSITIVE_INFINITY; } else { - claim.phase = phase; - if (claim.owner === "publisher") { - claim.expiresAt = Number.POSITIVE_INFINITY; - } + claim.publisherRegistrationClosed = true; } }; }; @@ -635,6 +677,10 @@ ts.divToSlotId = ts.divToSlotId || {}; ts.divToSlotId[element.id] = slot.id; ts.divToSlotId[slotElementId] = slot.id; + ts.prevSlotTargetingKeys = ts.prevSlotTargetingKeys || {}; + var targetingKeys = Object.keys(slot.targeting || {}); + ts.prevSlotTargetingKeys[element.id] = targetingKeys; + ts.prevSlotTargetingKeys[slotElementId] = targetingKeys; if (tsOwned) { ts.prevGptSlots = ts.prevGptSlots || []; ts.prevGptSlots.push(gptSlot); @@ -670,6 +716,7 @@ var slots = ts.adSlots || []; var bids = ts.bids || {}; var divToSlotId = {}; + var nextSlotTargetingKeys = {}; // Generation this invocation belongs to. The slot work below is queued on // googletag.cmd, which drains only when GPT loads; recheck first inside // the queued callback so a navigation committed in the gap cancels the @@ -783,8 +830,11 @@ // by the bundle's render bridge (index.ts) once it loads. divToSlotId[actualDivId] = slot.id; var slotElementId = s.getSlotElementId(); + var targetingKeys = Object.keys(slot.targeting || {}); + nextSlotTargetingKeys[actualDivId] = targetingKeys; if (slotElementId && slotElementId !== actualDivId) { divToSlotId[slotElementId] = slot.id; + nextSlotTargetingKeys[slotElementId] = targetingKeys; } if (tsOwned) { newSlots.push(s); @@ -796,6 +846,7 @@ }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; + ts.prevSlotTargetingKeys = nextSlotTargetingKeys; var hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; if (!ts.servicesEnabled && hasRenderableWork) { 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 e80ea8753..05252fcc1 100644 --- a/crates/trusted-server-js/lib/src/core/first_impression.ts +++ b/crates/trusted-server-js/lib/src/core/first_impression.ts @@ -1,3 +1,4 @@ +import { resolveSlotElementByDivId } from './slot_element'; import type { FirstImpressionPhase, FirstImpressionPublisherAuction, @@ -25,7 +26,9 @@ function claimMatchesElement( claim.generation === generation && claim.slotElementId === element.id && claim.element === element && - element.isConnected + element.ownerDocument === document && + element.isConnected && + document.getElementById(element.id) === element ); } @@ -56,16 +59,25 @@ function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressi state.slots ??= {}; state.fallbackSlots ??= {}; for (const [elementId, claim] of Object.entries(state.slots)) { - if (!claimMatchesElement(claim, claim.element, generation)) { + if ( + claim.slotElementId !== elementId || + !claimMatchesElement(claim, claim.element, generation) + ) { delete state.slots[elementId]; continue; } + const hasReservedFallback = + claim.owner === 'publisher' && + (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && + state.fallbackSlots[elementId] === claim.element; for (const [token, auction] of Object.entries(claim.publisherAuctions)) { // 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. + // this physical element and navigation. Publisher registrations also stay + // intact while an expired claim is waiting to transition to its reserved + // TS fallback, so an overlapping late callback cannot escape suppression. if ( auction.expiresAt <= now && + !hasReservedFallback && !(claim.owner === 'trusted_server' && auction.suppressDelivery) ) { removePublisherAuction(state, claim, token, now); @@ -75,7 +87,8 @@ function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressi claim.owner === 'publisher' && (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && Object.keys(claim.publisherAuctions).length === 0 && - claim.expiresAt <= now + claim.expiresAt <= now && + !hasReservedFallback ) { delete state.slots[elementId]; } @@ -92,31 +105,9 @@ function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressi return state; } -function activePhysicalElement(element: HTMLElement | null): HTMLElement | undefined { - return element?.isConnected && element.id ? element : undefined; -} - -function visibleThroughAncestors(element: HTMLElement): boolean { - for (let current: HTMLElement | null = element; current; current = current.parentElement) { - const style = window.getComputedStyle(current); - if (style.display === 'none' || style.visibility === 'hidden') return false; - } - return true; -} - -/** Resolve a publisher ad-unit code to one exact active physical slot element. */ +/** Resolve a publisher ad-unit code with the same contract GPT uses. */ export function resolveFirstImpressionElement(adUnitCode: string): HTMLElement | undefined { - if (!adUnitCode) return undefined; - const exact = activePhysicalElement(document.getElementById(adUnitCode)); - if (exact) return exact; - - const matches = Array.from(document.querySelectorAll('[id]')).filter( - (element) => - element.id.startsWith(adUnitCode) && - !element.id.endsWith('-container') && - visibleThroughAncestors(element) - ); - return matches.length === 1 ? matches[0] : undefined; + return resolveSlotElementByDivId(adUnitCode).element ?? undefined; } /** Return the live ownership claim for an exact slot element. */ @@ -148,7 +139,23 @@ export function claimFirstImpressionForTrustedServer( ): FirstImpressionSlotClaim | undefined { const state = pruneFirstImpressionState(ts, now); const existing = state.slots[element.id]; - if (existing && claimMatchesElement(existing, element, state.generation)) return undefined; + if (existing && claimMatchesElement(existing, element, state.generation)) { + const canTransitionPublisherFallback = + existing.owner === 'publisher' && + existing.phase !== 'requested' && + existing.phase !== 'rendered' && + existing.expiresAt <= now && + state.fallbackSlots[element.id] === element; + if (!canTransitionPublisherFallback) return undefined; + + existing.owner = 'trusted_server'; + existing.phase = 'delivery_pending'; + existing.expiresAt = now + FIRST_IMPRESSION_LEASE_MS; + for (const auction of Object.values(existing.publisherAuctions)) { + auction.suppressDelivery = true; + } + return existing; + } const claim: FirstImpressionSlotClaim = { generation: state.generation, @@ -180,10 +187,12 @@ export function releaseTrustedServerFirstImpressionClaim( if ( state.slots[element.id] === claim && claim.owner === 'trusted_server' && - claim.phase === 'delivery_pending' && - Object.keys(claim.publisherAuctions).length === 0 + claim.phase === 'delivery_pending' ) { delete state.slots[element.id]; + if (state.fallbackSlots[element.id] === element) { + delete state.fallbackSlots[element.id]; + } } } diff --git a/crates/trusted-server-js/lib/src/core/slot_element.ts b/crates/trusted-server-js/lib/src/core/slot_element.ts new file mode 100644 index 000000000..b7cf47d88 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/slot_element.ts @@ -0,0 +1,83 @@ +/** Result of resolving one configured slot div ID against the live DOM. */ +export interface SlotElementResolution { + element: HTMLElement | null; + prefixMatchCount: number; + activeMatchCount: number; +} + +function isElementVisible(element: HTMLElement): boolean { + const elementWithVisibilityCheck = element as HTMLElement & { + checkVisibility?: (options?: { + checkVisibilityCSS?: boolean; + visibilityProperty?: boolean; + }) => boolean; + }; + if (typeof elementWithVisibilityCheck.checkVisibility === 'function') { + return elementWithVisibilityCheck.checkVisibility({ + checkVisibilityCSS: true, + visibilityProperty: true, + }); + } + + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + const style = window.getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' + ) { + return false; + } + } + return true; +} + +function slotElementHasLayout(element: HTMLElement): boolean { + if (!isElementVisible(element)) return false; + const elementRect = element.getBoundingClientRect(); + if (elementRect.width > 0 && elementRect.height > 0) return true; + + const container = document.getElementById(`${element.id}-container`); + if (!container || !isElementVisible(container)) return false; + const containerRect = container.getBoundingClientRect(); + return containerRect.width > 0; +} + +/** Resolve an exact ID or one unambiguous visible/layout prefix match. */ +export function resolveSlotElementByDivId(divId: string): SlotElementResolution { + if (!divId) { + return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; + } + + const exact = document.getElementById(divId); + if (exact) { + return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; + } + + const prefixMatches = Array.from(document.querySelectorAll('[id]')).filter( + (element) => element.id.startsWith(divId) && !element.id.endsWith('-container') + ); + if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0]!)) { + return { + element: prefixMatches[0]!, + prefixMatchCount: 1, + activeMatchCount: 1, + }; + } + + const visibleMatches = prefixMatches.filter(isElementVisible); + if (visibleMatches.length === 1) { + return { + element: visibleMatches[0]!, + prefixMatchCount: prefixMatches.length, + activeMatchCount: 1, + }; + } + + const activeMatches = visibleMatches.filter(slotElementHasLayout); + return { + element: activeMatches.length === 1 ? activeMatches[0]! : null, + prefixMatchCount: prefixMatches.length, + activeMatchCount: activeMatches.length, + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index adec0b036..610271bd1 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -70,8 +70,7 @@ function sourceMatchedCandidates( source?: MessageEventSource | null ): HTMLElement[] { if (!source) return candidates; - const sourceMatches = candidates.filter((element) => sourceBelongsToElement(source, element)); - return sourceMatches.length > 0 ? sourceMatches : candidates; + return candidates.filter((element) => sourceBelongsToElement(source, element)); } function dynamicSlotCandidates( @@ -103,23 +102,26 @@ function findApsContainer(slotId: string, source?: MessageEventSource | null): H if (slotId.endsWith('-container')) { const inner = findSlot(slotId.slice(0, -'-container'.length)); - if (inner) return inner; + if (inner) return source && !sourceBelongsToElement(source, inner) ? null : inner; } const direct = findSlot(slotId); - if (direct && !direct.id.endsWith('-container')) return direct; + if (direct && !direct.id.endsWith('-container')) { + return source && !sourceBelongsToElement(source, direct) ? null : direct; + } const configuredDivId = window.tsjs?.adSlots?.find((slot) => slot.id === slotId)?.div_id; if (configuredDivId) { const configured = findSlot(configuredDivId); - if (configured) return configured; + if (configured) { + return source && !sourceBelongsToElement(source, configured) ? null : configured; + } const dynamic = uniqueSlotCandidate(dynamicSlotCandidates(configuredDivId, source)); if (dynamic) return dynamic; } - const dynamic = uniqueSlotCandidate(dynamicSlotCandidates(slotId, source)); - return dynamic ?? direct; + return uniqueSlotCandidate(dynamicSlotCandidates(slotId, source)); } catch { return null; } 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 7d4b4585c..eb521254d 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -7,6 +7,7 @@ import { reservePublisherFirstImpressionFallback, } from '../../core/first_impression'; import { log } from '../../core/log'; +import { resolveSlotElementByDivId } from '../../core/slot_element'; import type { AuctionSlot, AuctionBidData, @@ -91,95 +92,6 @@ interface SlotRenderEndedEvent { slot: GoogleTagSlot; } -interface SlotElementResolution { - element: HTMLElement | null; - prefixMatchCount: number; - activeMatchCount: number; -} - -function isElementVisible(element: HTMLElement): boolean { - const elementWithVisibilityCheck = element as HTMLElement & { - checkVisibility?: (options?: { - checkVisibilityCSS?: boolean; - visibilityProperty?: boolean; - }) => boolean; - }; - if (typeof elementWithVisibilityCheck.checkVisibility === 'function') { - return elementWithVisibilityCheck.checkVisibility({ - checkVisibilityCSS: true, - visibilityProperty: true, - }); - } - - for (let current: HTMLElement | null = element; current; current = current.parentElement) { - const style = window.getComputedStyle(current); - if ( - style.display === 'none' || - style.visibility === 'hidden' || - style.visibility === 'collapse' - ) { - return false; - } - } - return true; -} - -function slotElementHasLayout(element: HTMLElement): boolean { - if (!isElementVisible(element)) return false; - const elementRect = element.getBoundingClientRect(); - if (elementRect.width > 0 && elementRect.height > 0) return true; - - const container = document.getElementById(`${element.id}-container`); - if (!container || !isElementVisible(container)) return false; - const containerRect = container.getBoundingClientRect(); - return containerRect.width > 0; -} - -function resolveSlotElementByDivId(divId: string): SlotElementResolution { - if (!divId) { - return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; - } - // Exact-id matches intentionally skip the visibility tiers below: a - // configured literal id is unambiguous, so a hidden match is still the - // right element (adInit defines the slot; GPT simply renders nothing while - // it is hidden). Prefix matches go through the tiers because a prefix can - // match several candidates and only visibility/layout disambiguates them — - // so a hidden exact-id match resolves while a hidden prefix match does not. - const exact = document.getElementById(divId); - if (exact) { - return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; - } - - const prefixMatches = Array.from(document.querySelectorAll('[id]')).filter( - (element) => element.id.startsWith(divId) && !element.id.endsWith('-container') - ); - // A unique prefix match may be a lazy slot that has not been sized yet, but - // it must still be visible through its ancestor containers. - if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0]!)) { - return { - element: prefixMatches[0]!, - prefixMatchCount: 1, - activeMatchCount: 1, - }; - } - - const visibleMatches = prefixMatches.filter(isElementVisible); - if (visibleMatches.length === 1) { - return { - element: visibleMatches[0]!, - prefixMatchCount: prefixMatches.length, - activeMatchCount: 1, - }; - } - - const activeMatches = visibleMatches.filter(slotElementHasLayout); - return { - element: activeMatches.length === 1 ? activeMatches[0]! : null, - prefixMatchCount: prefixMatches.length, - activeMatchCount: activeMatches.length, - }; -} - function findSlotElementByDivId(divId: string): HTMLElement | null { return resolveSlotElementByDivId(divId).element; } @@ -220,20 +132,43 @@ function sourceFrameInRoots( return { iframe, root }; } +function sourceFrameForConfiguredDivId( + source: MessageEventSource | null, + divId: string +): MessageSourceFrame | undefined { + const exact = document.getElementById(divId); + const candidates = exact + ? [exact] + : Array.from(document.querySelectorAll('[id]')).filter( + (element) => element.id.startsWith(divId) && !element.id.endsWith('-container') + ); + const matches = candidates + .map((element) => sourceFrameInRoots(source, candidateSlotRoots(element.id))) + .filter((frame): frame is MessageSourceFrame => frame !== undefined); + return matches.length === 1 ? matches[0] : undefined; +} + +function uniqueSourceFrame( + frames: Array +): MessageSourceFrame | undefined { + const matches = new Map(); + for (const frame of frames) { + if (frame) matches.set(frame.iframe, frame); + } + return matches.size === 1 ? matches.values().next().value : undefined; +} + function sourceFrameForSlotId( source: MessageEventSource | null, slotId: string ): MessageSourceFrame | undefined { - const mappedRoots = Object.entries(window.tsjs?.divToSlotId ?? {}) + const mappedFrames = Object.entries(window.tsjs?.divToSlotId ?? {}) .filter(([, mappedSlotId]) => mappedSlotId === slotId) - .flatMap(([elementId]) => candidateSlotRoots(elementId)); - const configuredRoots = (window.tsjs?.adSlots ?? []) + .map(([elementId]) => sourceFrameInRoots(source, candidateSlotRoots(elementId))); + const configuredFrames = (window.tsjs?.adSlots ?? []) .filter((slot) => slot.id === slotId) - .flatMap((slot) => { - const element = resolveSlotElementByDivId(slot.div_id).element; - return element ? candidateSlotRoots(element.id) : []; - }); - return sourceFrameInRoots(source, [...new Set([...mappedRoots, ...configuredRoots])]); + .map((slot) => sourceFrameForConfiguredDivId(source, slot.div_id)); + return uniqueSourceFrame([...mappedFrames, ...configuredFrames]); } interface MessageSourceSlotFrame extends MessageSourceFrame { @@ -248,10 +183,7 @@ function slotFrameForMessageSource( if (sourceFrameInRoots(source, candidateSlotRoots(elementId))) slotIds.add(slotId); } for (const slot of window.tsjs?.adSlots ?? []) { - const element = resolveSlotElementByDivId(slot.div_id).element; - if (element && sourceFrameInRoots(source, candidateSlotRoots(element.id))) { - slotIds.add(slot.id); - } + if (sourceFrameForConfiguredDivId(source, slot.div_id)) slotIds.add(slot.id); } if (slotIds.size !== 1) return undefined; const slotId = slotIds.values().next().value as string; @@ -263,8 +195,7 @@ function sourceFrameForAdUnit( source: MessageEventSource | null, adUnitCode: string ): MessageSourceFrame | undefined { - const element = resolveSlotElementByDivId(adUnitCode).element; - return element ? sourceFrameInRoots(source, candidateSlotRoots(element.id)) : undefined; + return sourceFrameForConfiguredDivId(source, adUnitCode); } function hasCollapsedDimension(element: HTMLElement, dimension: 'width' | 'height'): boolean { @@ -296,7 +227,7 @@ function creativeFrameIsCurrent( ); } -/** Resize only the authenticated source iframe for a still-current collapsed display shell. */ +/** Resize the authenticated source iframe and collapsed ancestors through its slot root. */ function resizeCollapsedCreativeFrame( source: MessageEventSource | null, frame: MessageSourceFrame, @@ -1106,6 +1037,26 @@ function applyTrustedServerTargeting( return Object.keys(slot.targeting ?? {}); } +function clearPreviousNavigationTargeting(ts: TsjsApi, g: Partial): void { + const previousKeys = ts.prevSlotTargetingKeys ?? {}; + const touchedElementIds = new Set([ + ...Object.keys(previousKeys), + ...Object.keys(ts.divToSlotId ?? {}), + ]); + + const pubads = g.pubads?.(); + if (pubads && touchedElementIds.size > 0) { + for (const slot of pubads.getSlots?.() ?? []) { + const elementId = slot.getSlotElementId(); + if (!touchedElementIds.has(elementId)) continue; + clearTargetingKeys(slot, [...TS_BASE_TARGETING_KEYS, ...(previousKeys[elementId] ?? [])]); + } + } + + ts.prevSlotTargetingKeys = {}; + ts.divToSlotId = {}; +} + function schedulePublisherFirstImpressionFallback( ts: TsjsApi, g: Partial, @@ -1692,6 +1643,8 @@ export function installSpaAuctionHook(): void { async function onNavigate(path: string): Promise { if (path === currentPath) return; currentPath = path; + const g = (window as GptWindow).googletag; + if (g) clearPreviousNavigationTargeting(ts, g); ts.navGeneration = (ts.navGeneration ?? 0) + 1; delete ts.firstImpression; // A route change invalidates hydration aliases before the new route's @@ -1755,9 +1708,13 @@ export function installSpaAuctionHook(): void { patchHistoryMethod('pushState'); patchHistoryMethod('replaceState'); - window.addEventListener('popstate', () => { - void onNavigate(location.pathname); - }); + window.addEventListener( + 'popstate', + () => { + void onNavigate(location.pathname); + }, + true + ); } /** 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 ce5020996..443832bd2 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -15,6 +15,7 @@ import type _pbjsDefault from 'prebid.js'; import { consumePublisherFirstImpressionDelivery, + FIRST_IMPRESSION_LEASE_MS, firstImpressionClaim, markPublisherFirstImpressionDeliveryPending, registerPublisherFirstImpressionAuctions, @@ -138,7 +139,7 @@ const TS_REFRESH_TARGETING_KEYS = [ ] as const; const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; const MAX_PENDING_PUBLISHER_BIDS = 2048; -const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000; +const PENDING_PUBLISHER_DELIVERY_TTL_MS = FIRST_IMPRESSION_LEASE_MS; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -901,6 +902,24 @@ function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: } } +function removeConsumedPublisherRegistration(adUnitCode: string, registrationId: number): void { + const registrations = pendingPublisherCodes.get(adUnitCode); + const pendingCode = registrations?.get(registrationId); + registrations?.delete(registrationId); + if (registrations?.size === 0) pendingPublisherCodes.delete(adUnitCode); + + const tokens = new Set(); + if (pendingCode?.firstImpressionToken) tokens.add(pendingCode.firstImpressionToken); + for (const [adId, pendingBid] of pendingPublisherBids) { + if (pendingBid.adUnitCode !== adUnitCode || pendingBid.registrationId !== registrationId) { + continue; + } + pendingPublisherBids.delete(adId); + if (pendingBid.firstImpressionToken) tokens.add(pendingBid.firstImpressionToken); + } + for (const token of tokens) forgetPublisherFirstImpressionToken(adUnitCode, token); +} + function pendingPublisherContextIsCurrent( pending: PendingPublisherBid | PendingPublisherCode ): boolean { @@ -1133,26 +1152,33 @@ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): PublisherDeliver const hasAdId = Array.isArray(adIds) && adIds.some((adId) => typeof adId === 'string' && adId.length > 0); const injectedSlot = findInjectedSlotForRefresh(slot); - 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 pendingCodeCandidates = [ + ...new Map( + [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) + ) + .map((pending) => [pending.registrationId, pending] as const) + ).values(), + ].sort((left, right) => left.registrationId - right.registrationId); + const pendingCode = pendingCodeCandidates.length === 1 ? pendingCodeCandidates[0] : undefined; const pending = pendingBid ?? pendingCode; - if (!pending) continue; + if (!pending) { + if (pendingCodeCandidates.some((candidate) => candidate.retainUntilContextChange)) { + suppressedSlots.add(slot); + } + continue; + } const suppress = pending.firstImpressionToken && window.tsjs ? consumePublisherFirstImpressionDelivery(window.tsjs, pending.firstImpressionToken) : false; - if (pending.firstImpressionToken) { - forgetPublisherFirstImpressionToken(pending.adUnitCode, pending.firstImpressionToken); - } - removePendingPublisherBidsForCode(pending.adUnitCode); + removeConsumedPublisherRegistration(pending.adUnitCode, pending.registrationId); (suppress ? suppressedSlots : deliverySlots).add(slot); } 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 91bac02b3..e034a8ba4 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 @@ -6,7 +6,10 @@ import { resolve } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import envelope from '../../fixtures/aps-renderer-v1.json'; -import { registerPublisherFirstImpressionAuctions } from '../../../src/core/first_impression'; +import { + registerPublisherFirstImpressionAuctions, + resolveFirstImpressionElement, +} from '../../../src/core/first_impression'; import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; import { APS_PREBID_CREATIVE_RUNNER_URL, @@ -2791,6 +2794,7 @@ describe('installTsAdInit', () => { ) ); const selectedElement = selectedIndex === null ? undefined : elements[selectedIndex]; + expect(resolveFirstImpressionElement(divId)).toBe(selectedElement); const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -3767,7 +3771,7 @@ describe('installTsRenderBridge', () => { } }); - it('does not use the requesting frame to disambiguate a registered APS slot prefix', async () => { + it('uses the requesting frame to disambiguate a registered APS slot prefix', async () => { const renderer = apsRenderer(); const prebidAdId = 'native-dynamic-prebid-ad-id'; const markUsed = vi.fn(); @@ -3795,9 +3799,14 @@ describe('installTsRenderBridge', () => { }) as unknown as MessageEvent ); - expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); - expect(markUsed).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); + const native = nativeRunnerIn('div-native-second'); + native.runner.dispatchEvent(new Event('load')); + await Promise.resolve(); + await Promise.resolve(); + + expect(native.frame.style.display).toBe(''); + expect(markUsed).toHaveBeenCalledOnce(); + expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); } finally { marker.remove(); document.getElementById('div-native-first')?.remove(); @@ -4491,7 +4500,86 @@ describe('installTsRenderBridge', () => { expect(fetchStub).not.toHaveBeenCalled(); }); - it('uses the adInit-resolved div when a responsive prefix becomes ambiguous', async () => { + it('uses the requesting frame to resolve inline adm under an ambiguous prefix', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const tsjs = (window as TestWindow).tsjs!; + tsjs.bids.homepage_header.adm = '
Prefix inline creative
'; + delete tsjs.bids.homepage_header.hb_cache_host; + delete tsjs.bids.homepage_header.hb_cache_path; + tsjs.adSlots = [ + { + id: 'homepage_header', + formats: [[728, 90]], + gam_unit_path: '/a/b/c', + div_id: 'div-inline-prefix-', + targeting: {}, + }, + ]; + tsjs.divToSlotId = {}; + createTrustedSlotIframe('div-inline-prefix-first'); + const source = createTrustedSlotIframe('div-inline-prefix-second'); + const bridgeListener = await captureBridgeListener(); + const postMessage = vi.fn(); + const stopImmediatePropagation = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage }], + source, + stopImmediatePropagation, + }) as unknown as MessageEvent + ); + + expect(postMessage).toHaveBeenCalledOnce(); + expect(JSON.parse(postMessage.mock.calls[0]![0])).toEqual( + expect.objectContaining({ ad: '
Prefix inline creative
' }) + ); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(fetchStub).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + + it('rejects a requesting frame owned by multiple prefix candidates', async () => { + const tsjs = (window as TestWindow).tsjs!; + tsjs.bids.homepage_header.adm = '
Ambiguous inline creative
'; + tsjs.adSlots = [ + { + id: 'homepage_header', + formats: [[728, 90]], + gam_unit_path: '/a/b/c', + div_id: 'div-nested-prefix-', + targeting: {}, + }, + ]; + tsjs.divToSlotId = {}; + const outer = document.createElement('div'); + outer.id = 'div-nested-prefix-outer'; + const inner = document.createElement('div'); + inner.id = 'div-nested-prefix-inner'; + const iframe = document.createElement('iframe'); + inner.appendChild(iframe); + outer.appendChild(inner); + document.body.appendChild(outer); + const bridgeListener = await captureBridgeListener(); + const postMessage = vi.fn(); + const stopImmediatePropagation = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage }], + source: iframe.contentWindow, + stopImmediatePropagation, + }) as unknown as MessageEvent + ); + + expect(postMessage).not.toHaveBeenCalled(); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it('uses the requesting frame when a responsive prefix is ambiguous', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); fetchStub.mockResolvedValue({ ok: true, @@ -4516,9 +4604,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ]; - (window as TestWindow).tsjs!.divToSlotId = { - 'div-responsive-a': 'homepage_header', - }; + (window as TestWindow).tsjs!.divToSlotId = {}; const bridgeListener = await captureBridgeListener(); const portMessages: string[] = []; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index a9c84cc61..96c2fc893 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -3,7 +3,8 @@ import path from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import { FIRST_IMPRESSION_LEASE_MS } from '../../../src/core/first_impression'; +import type { FirstImpressionSlotClaim, TsjsApi } from '../../../src/core/types'; /** * Executable coverage for the edge-injected `gpt_bootstrap.js` — the @@ -233,6 +234,278 @@ describe('gpt_bootstrap.js fallback', () => { expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); }); + it('keeps the bootstrap lease synchronized with the bundle contract', () => { + const bootstrapLease = /var FIRST_IMPRESSION_LEASE_MS = (\d+);/.exec(BOOTSTRAP_SOURCE); + + expect(Number(bootstrapLease?.[1])).toBe(FIRST_IMPRESSION_LEASE_MS); + }); + + it('clears the bootstrap fallback reservation when transitioned slot setup fails', () => { + vi.useFakeTimers(); + vi.setSystemTime(100); + try { + const pubads = { + getSlots: vi.fn(() => []), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = makeGoogleTag({ + cmd: { push: (command) => command() }, + defineSlot: vi.fn(() => null), + pubads: vi.fn(() => pubads), + }); + document.body.innerHTML = '
'; + + runBootstrap(); + const ts = (window as TestWindow).tsjs!; + const element = document.getElementById('failed-bootstrap-fallback')!; + const publisherClaim: FirstImpressionSlotClaim = { + generation: 0, + slotElementId: element.id, + element, + owner: 'publisher', + phase: 'auctioning', + expiresAt: 5_100, + publisherAuctions: { + original: { + token: 'original', + adUnitCode: element.id, + phase: 'auctioning', + expiresAt: 5_100, + adIds: [], + suppressDelivery: false, + }, + }, + }; + ts.firstImpression = { + generation: 0, + nextToken: 1, + slots: { [element.id]: publisherClaim }, + fallbackSlots: {}, + }; + ts.adSlots = [ + { + id: 'failed-bootstrap-fallback-ad', + gam_unit_path: '/123/failed-bootstrap-fallback', + div_id: element.id, + formats: [[300, 250]], + targeting: {}, + }, + ]; + ts.bids = { 'failed-bootstrap-fallback-ad': { hb_pb: '1.00' } }; + + ts.adInit!(); + expect(ts.firstImpression.fallbackSlots[element.id]).toBe(element); + + vi.advanceTimersByTime(5_001); + + expect(ts.firstImpression.slots[element.id]).toBeUndefined(); + expect(ts.firstImpression.fallbackSlots[element.id]).toBeUndefined(); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it('retains an expired TS suppression tombstone in the persistent bootstrap listener', () => { + const queue: Array<() => void> = []; + const listeners = new Map void>(); + const pubads = { + addEventListener: vi.fn((name: string, listener: (event: never) => void) => { + listeners.set(name, listener as (event: { slot: { getSlotElementId(): string } }) => void); + }), + getSlots: vi.fn(() => []), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = makeGoogleTag({ + cmd: queue, + pubads: vi.fn(() => pubads), + }); + document.body.innerHTML = '
'; + + runBootstrap(); + [...queue].forEach((command) => command()); + const element = document.getElementById('persistent-slot')!; + const claim: FirstImpressionSlotClaim = { + generation: 0, + slotElementId: element.id, + element, + owner: 'trusted_server', + phase: 'delivery_pending', + expiresAt: 0, + publisherAuctions: { + late: { + token: 'late', + adUnitCode: element.id, + phase: 'delivery_pending', + expiresAt: 0, + adIds: ['late-ad'], + suppressDelivery: true, + }, + }, + }; + (window as TestWindow).tsjs!.firstImpression = { + generation: 0, + nextToken: 1, + slots: { [element.id]: claim }, + fallbackSlots: {}, + }; + + listeners.get('slotRequested')!({ slot: { getSlotElementId: () => element.id } }); + + expect(claim.publisherAuctions.late).toBeDefined(); + expect(claim.publisherRegistrationClosed).toBe(true); + }); + + it('prunes a malformed bootstrap registry key before recording the main-document slot', () => { + const queue: Array<() => void> = []; + const listeners = new Map void>(); + const pubads = { + addEventListener: vi.fn((name: string, listener: (event: never) => void) => { + listeners.set(name, listener as (event: { slot: { getSlotElementId(): string } }) => void); + }), + getSlots: vi.fn(() => []), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = makeGoogleTag({ + cmd: queue, + pubads: vi.fn(() => pubads), + }); + document.body.innerHTML = '
'; + + runBootstrap(); + [...queue].forEach((command) => command()); + const element = document.getElementById('malformed-bootstrap-slot')!; + const malformedClaim: FirstImpressionSlotClaim = { + generation: 0, + slotElementId: element.id, + element, + owner: 'trusted_server', + phase: 'delivery_pending', + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }; + (window as TestWindow).tsjs!.firstImpression = { + generation: 0, + nextToken: 0, + slots: { 'wrong-registry-key': malformedClaim }, + fallbackSlots: {}, + }; + + listeners.get('slotRequested')!({ slot: { getSlotElementId: () => element.id } }); + + const slots = (window as TestWindow).tsjs!.firstImpression!.slots; + expect(slots['wrong-registry-key']).toBeUndefined(); + expect(slots[element.id]).toEqual( + expect.objectContaining({ element, owner: 'publisher', phase: 'requested' }) + ); + }); + + it('rejects a connected same-ID bootstrap claim from a foreign document', () => { + const queue: Array<() => void> = []; + const listeners = new Map void>(); + const pubads = { + addEventListener: vi.fn((name: string, listener: (event: never) => void) => { + listeners.set(name, listener as (event: { slot: { getSlotElementId(): string } }) => void); + }), + getSlots: vi.fn(() => []), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = makeGoogleTag({ + cmd: queue, + pubads: vi.fn(() => pubads), + }); + document.body.innerHTML = '
'; + + runBootstrap(); + [...queue].forEach((command) => command()); + const element = document.getElementById('foreign-bootstrap-slot')!; + const foreignDocument = document.implementation.createHTMLDocument('foreign'); + const foreignElement = foreignDocument.createElement('div'); + foreignElement.id = element.id; + foreignDocument.body.appendChild(foreignElement); + const foreignClaim: FirstImpressionSlotClaim = { + generation: 0, + slotElementId: element.id, + element: foreignElement, + owner: 'trusted_server', + phase: 'delivery_pending', + expiresAt: 0, + publisherAuctions: { + foreign: { + token: 'foreign', + adUnitCode: element.id, + phase: 'delivery_pending', + expiresAt: 0, + adIds: ['foreign-ad'], + suppressDelivery: true, + }, + }, + }; + (window as TestWindow).tsjs!.firstImpression = { + generation: 0, + nextToken: 1, + slots: { [element.id]: foreignClaim }, + fallbackSlots: {}, + }; + + expect(foreignElement.isConnected).toBe(true); + listeners.get('slotRequested')!({ slot: { getSlotElementId: () => element.id } }); + + const currentClaim = (window as TestWindow).tsjs!.firstImpression!.slots[element.id]; + expect(currentClaim).toEqual( + expect.objectContaining({ element, owner: 'publisher', phase: 'requested' }) + ); + expect(currentClaim!.publisherAuctions).toEqual({}); + }); + + it('refuses a 257th bootstrap lifecycle claim without evicting live claims', () => { + const queue: Array<() => void> = []; + const listeners = new Map void>(); + const pubads = { + addEventListener: vi.fn((name: string, listener: (event: never) => void) => { + listeners.set(name, listener as (event: { slot: { getSlotElementId(): string } }) => void); + }), + getSlots: vi.fn(() => []), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = makeGoogleTag({ + cmd: queue, + pubads: vi.fn(() => pubads), + }); + + runBootstrap(); + [...queue].forEach((command) => command()); + const slots: Record = {}; + for (let index = 0; index < 256; index += 1) { + const element = document.createElement('div'); + element.id = `bounded-slot-${index}`; + document.body.appendChild(element); + slots[element.id] = { + generation: 0, + slotElementId: element.id, + element, + owner: 'publisher', + phase: 'rendered', + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }; + } + (window as TestWindow).tsjs!.firstImpression = { + generation: 0, + nextToken: 0, + slots, + fallbackSlots: {}, + }; + const overflow = document.createElement('div'); + overflow.id = 'bounded-slot-overflow'; + document.body.appendChild(overflow); + + listeners.get('slotRequested')!({ slot: { getSlotElementId: () => overflow.id } }); + + expect(Object.keys(slots)).toHaveLength(256); + expect(slots[overflow.id]).toBeUndefined(); + }); + it('installs fallback adInit and scheduleInitialAdInit when the bundle is absent', () => { runBootstrap(); const ts = (window as TestWindow).tsjs!; @@ -419,6 +692,7 @@ describe('gpt_bootstrap.js fallback', () => { gam_unit_path: '/123/atf', div_id: 'div-atf-sidebar', formats: [[300, 250]], + targeting: { ts_route: 'home' }, }, ]; ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; @@ -428,6 +702,8 @@ describe('gpt_bootstrap.js fallback', () => { expect(defineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], 'div-atf-sidebar'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_route', 'home'); + expect(ts.prevSlotTargetingKeys).toEqual({ 'div-atf-sidebar': ['ts_route'] }); expect(display).toHaveBeenCalledWith('div-atf-sidebar'); expect(ts.servicesEnabled).toBe(true); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts index f684d7188..1b6488f73 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts @@ -612,7 +612,7 @@ describe('GPT GAM attribution bundle fallback', () => { expect(typeof win.tsjs?.adInit).toBe('function'); expect(typeof win.tsjs?.scheduleInitialAdInit).toBe('function'); expect(win.tsjs?.spaHookInstalled).toBe(true); - expect(addEventListenerSpy).toHaveBeenCalledWith('popstate', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('popstate', expect.any(Function), true); expect(addEventListenerSpy).toHaveBeenCalledWith('load', expect.any(Function)); expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); if (setConfig) { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 314348fa8..979b5b0c7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -61,7 +61,7 @@ describe('installSpaAuctionHook', () => { // Drop any ad containers inserted by a test so DOM state does not leak. document.body.innerHTML = ''; // Remove this test's popstate listener(s) so they do not fire in later tests. - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); + popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler, true)); popstateHandlers = []; vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -237,9 +237,9 @@ describe('installSpaAuctionHook', () => { expect(adInit).not.toHaveBeenCalled(); }); - it('runs adInit on an empty page-bids response when prior TS state exists', async () => { - // When TS touched slots on a previous navigation, an empty response still - // needs adInit() to sweep the stale TS targeting from those slots. + it('does not defer cleanup to adInit when an empty response has only prior targeting', async () => { + // Navigation clears prior targeting synchronously, so an empty response + // does not need adInit when TS owns no slots that still require destruction. fetchStub.mockResolvedValue({ ok: true, json: async () => ({ slots: [], bids: {} }), @@ -255,7 +255,103 @@ describe('installSpaAuctionHook', () => { await flushAsync(); expect(ts.adSlots).toEqual([]); - expect(adInit).toHaveBeenCalledTimes(1); + expect(adInit).not.toHaveBeenCalled(); + }); + + it('clears prior targeting before page-bids resolves without touching new publisher targeting', async () => { + let resolveFetch: ((response: Response) => void) | undefined; + fetchStub.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const element = document.createElement('div'); + element.id = 'div-route-slot'; + document.body.appendChild(element); + const clearTargeting = vi.fn(); + const gptSlot = { + addService: vi.fn().mockReturnThis(), + clearTargeting, + getSlotElementId: vi.fn().mockReturnValue(element.id), + getTargeting: vi.fn().mockReturnValue([]), + setTargeting: vi.fn().mockReturnThis(), + }; + const pubads = { + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([gptSlot]), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(gptSlot), + destroySlots: vi.fn(), + display: vi.fn(), + enableServices: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + + const { installSpaAuctionHook, installTsAdInit } = await importGptModule(); + installTsAdInit(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + ts.prevSlotTargetingKeys = { [element.id]: ['ts_route'] }; + ts.divToSlotId = { [element.id]: 'route_slot' }; + + history.pushState({}, '', '/publisher-route'); + + expect(clearTargeting.mock.calls.map(([key]) => key)).toEqual([ + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', + 'ts_initial', + 'ts_route', + ]); + expect(ts.prevSlotTargetingKeys).toEqual({}); + expect(ts.divToSlotId).toEqual({}); + const cleanupCallCount = clearTargeting.mock.calls.length; + + ts.firstImpression = { + generation: 1, + nextToken: 0, + fallbackSlots: {}, + slots: { + [element.id]: { + generation: 1, + slotElementId: element.id, + element, + owner: 'publisher', + phase: 'auctioning', + expiresAt: Date.now() + 5000, + publisherAuctions: {}, + }, + }, + }; + gptSlot.setTargeting('hb_adid', 'publisher-current'); + resolveFetch!( + new Response( + JSON.stringify({ + slots: [ + { + id: 'route_slot', + gam_unit_path: '/123/route', + div_id: element.id, + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + await flushAsync(); + + expect(clearTargeting).toHaveBeenCalledTimes(cleanupCallCount); + expect(gptSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'publisher-current'); }); it('defers applying bids until the route ad container is inserted', 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 67d38d9d5..e568b893f 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 @@ -200,12 +200,16 @@ import { installPrebidNpm, installRefreshHandler, } from '../../../src/integrations/prebid/index'; +import { installTsAdInit } from '../../../src/integrations/gpt/index'; import type { AuctionBid } from '../../../src/core/auction'; import { claimFirstImpressionForTrustedServer, consumePublisherFirstImpressionDelivery, + firstImpressionClaim, observeFirstImpressionGptLifecycle, registerPublisherFirstImpressionAuctions, + releaseTrustedServerFirstImpressionClaim, + reservePublisherFirstImpressionFallback, } from '../../../src/core/first_impression'; import { log } from '../../../src/core/log'; import type { TsjsApi } from '../../../src/core/types'; @@ -2635,6 +2639,113 @@ describe('prebid publisher snapshots and delivery refreshes', () => { element.remove(); }); + it('rejects a connected claim whose element is no longer canonical for its ID', () => { + const element = document.createElement('div'); + element.id = 'replaced-canonical-element'; + document.body.appendChild(element); + const ts = {} as TsjsApi; + claimFirstImpressionForTrustedServer(ts, element, 100); + const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); + const replacement = document.createElement('div'); + replacement.id = element.id; + document.body.insertBefore(replacement, element); + + expect(document.getElementById(element.id)).toBe(replacement); + expect(consumePublisherFirstImpressionDelivery(ts, token, 102)).toBe(false); + expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); + + replacement.remove(); + element.remove(); + }); + + it('prunes a claim stored under a registry key that does not match its slot element ID', () => { + const element = document.createElement('div'); + element.id = 'malformed-registry-key-slot'; + document.body.appendChild(element); + const ts = {} as TsjsApi; + const claim = claimFirstImpressionForTrustedServer(ts, element, 100)!; + delete ts.firstImpression!.slots[element.id]; + ts.firstImpression!.slots['wrong-registry-key'] = claim; + + expect(firstImpressionClaim(ts, element)).toBeUndefined(); + expect(ts.firstImpression!.slots['wrong-registry-key']).toBeUndefined(); + + element.remove(); + }); + + it('rejects a connected same-ID TS claim from a foreign document', () => { + const element = document.createElement('div'); + element.id = 'foreign-document-claim-slot'; + document.body.appendChild(element); + const foreignDocument = document.implementation.createHTMLDocument('foreign'); + const foreignElement = foreignDocument.createElement('div'); + foreignElement.id = element.id; + foreignDocument.body.appendChild(foreignElement); + const ts = {} as TsjsApi; + const claim = claimFirstImpressionForTrustedServer(ts, element, 100)!; + const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); + claim.element = foreignElement; + + expect(foreignElement.isConnected).toBe(true); + expect(consumePublisherFirstImpressionDelivery(ts, token, 102)).toBe(false); + expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); + expect(claimFirstImpressionForTrustedServer(ts, element, 103)?.element).toBe(element); + + element.remove(); + }); + + it('prunes an ordinary expired publisher registration without a reserved fallback', () => { + const element = document.createElement('div'); + element.id = 'ordinary-expired-publisher-slot'; + document.body.appendChild(element); + const ts = {} as TsjsApi; + const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 100).get(element.id); + + expect(consumePublisherFirstImpressionDelivery(ts, token, 5_101)).toBe(false); + expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); + + element.remove(); + }); + + it('clears a failed fallback reservation before a later ordinary publisher claim expires', () => { + vi.useFakeTimers(); + vi.setSystemTime(100); + try { + const element = document.createElement('div'); + element.id = 'failed-fallback-reservation-slot'; + document.body.appendChild(element); + const ts = {} as TsjsApi; + const originalToken = registerPublisherFirstImpressionAuctions(ts, [element.id]).get( + element.id + ); + expect(originalToken).toBeDefined(); + expect(reservePublisherFirstImpressionFallback(ts, element)).toBe(true); + + vi.advanceTimersByTime(5_001); + const fallbackClaim = claimFirstImpressionForTrustedServer(ts, element)!; + expect(fallbackClaim.owner).toBe('trusted_server'); + expect(fallbackClaim.publisherAuctions[originalToken!]?.suppressDelivery).toBe(true); + + releaseTrustedServerFirstImpressionClaim(ts, element, fallbackClaim); + expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); + expect(ts.firstImpression?.fallbackSlots[element.id]).toBeUndefined(); + + const laterToken = registerPublisherFirstImpressionAuctions(ts, [element.id]).get(element.id); + expect(laterToken).toBeDefined(); + vi.advanceTimersByTime(5_001); + expect(consumePublisherFirstImpressionDelivery(ts, laterToken)).toBe(false); + expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); + + const freshClaim = claimFirstImpressionForTrustedServer(ts, element)!; + expect(freshClaim.publisherAuctions).toEqual({}); + + element.remove(); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + it('reserves first impression while a publisher refresh auction is pending', () => { const code = 'pending-publisher-refresh-slot'; const slot = { @@ -2664,6 +2775,74 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); + it('suppresses an original publisher delivery after the lease-boundary TS fallback', () => { + vi.useFakeTimers(); + try { + const code = 'lease-boundary-fallback-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + setTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + let originalPublisherAuction: Parameters[0]; + mockRequestBids.mockImplementation((options) => { + if (!originalPublisherAuction) { + originalPublisherAuction = options; + return; + } + completePublisherAuction(options); + }); + const pbjs = installPrebidNpm(); + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + ts.servicesEnabled = true; + ts.adSlots = [ + { + id: 'lease-boundary-fallback-ad', + gam_unit_path: '/123/lease-boundary', + div_id: code, + formats: [[300, 250]], + targeting: {}, + }, + ]; + ts.bids = { + 'lease-boundary-fallback-ad': { + hb_pb: '1.00', + hb_adid: 'trusted-server-fallback-ad', + }, + }; + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot]), + } as unknown as RequestBidsArg); + installTsAdInit(); + ts.adInit!(); + + vi.advanceTimersByTime(5001); + observeFirstImpressionGptLifecycle(ts, document.getElementById(code)!, 'requested'); + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(ts.firstImpression?.slots[code]?.owner).toBe('trusted_server'); + expect(ts.firstImpression?.fallbackSlots[code]).toBe(document.getElementById(code)); + expect(Object.values(ts.firstImpression?.slots[code]?.publisherAuctions ?? {})).toEqual([ + expect.objectContaining({ suppressDelivery: true }), + ]); + + completePublisherAuction(originalPublisherAuction); + expect(originalRefresh).toHaveBeenCalledOnce(); + + deliveryAdIds.delete(slot); + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledTimes(2); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + it('suppresses a delayed publisher refresh when TS already owns first impression', () => { const code = 'pending-ts-owned-refresh-slot'; const slot = { @@ -4431,7 +4610,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); - it('consumes all overlapping pending bids for the same ad-unit code', () => { + it('preserves a sibling registration after consuming an exact overlapping delivery', () => { const code = 'example-overlapping-code'; const slot = { getSlotElementId: () => code, @@ -4443,26 +4622,89 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); + for (let index = 0; index < 2; index += 1) { + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as unknown as RequestBidsArg); + } + deliveryAdIds.set(slot, `example-auction-0-${code}`); pubads.refresh([slot]); + deliveryAdIds.set(slot, `example-auction-1-${code}`); + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + }); + + it('does not guess between ordinary overlapping code-only registrations', () => { + const code = 'example-ambiguous-code-only'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + for (let index = 0; index < 2; index += 1) { + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as unknown as RequestBidsArg); + } + + deliveryAdIds.delete(slot); + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(3); deliveryAdIds.set(slot, `example-auction-0-${code}`); pubads.refresh([slot]); expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + expect(originalRefresh).toHaveBeenCalledTimes(2); + }); + + it('fails closed without consuming TS-owned ambiguous code-only registrations', () => { + const code = 'example-ts-ambiguous-code-only'; + 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(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; + claimFirstImpressionForTrustedServer(ts, element); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + for (let index = 0; index < 2; index += 1) { + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as unknown as RequestBidsArg); + } + + deliveryAdIds.delete(slot); + pubads.refresh([slot]); + deliveryAdIds.set(slot, `example-auction-0-${code}`); + pubads.refresh([slot]); + deliveryAdIds.set(slot, `example-auction-1-${code}`); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).not.toHaveBeenCalled(); + element.remove(); }); it('filters invalid explicit entries without duplicating or leaking a valid delivery', () => { diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index cb1e8eee6..adfe38ea2 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -195,7 +195,7 @@ In `trusted_server` mode, the TSJS auction client validates the typed renderer d ### GAM and Universal Creative -For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid` and validates the complete envelope. In `trusted_server` mode it returns a static dynamic-renderer program that creates the same opaque renderer iframe. After the response is delivered, the bridge expands an authenticated ordinary display iframe only when its width and height attributes and computed geometry are still 1x1. It resizes that source iframe and its immediate collapsed shell parent to the validated winning dimensions. Ambiguous sources, stale navigation or refresh completions, anchors, interstitials, fixed or sticky frames, invalid dimensions, and already-expanded frames remain unchanged. The same guard applies to APS capabilities, inline `adm`, and PBS Cache responses. +For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid` and validates the complete envelope. In `trusted_server` mode it returns a static dynamic-renderer program that creates the same opaque renderer iframe. After the response is delivered, the bridge expands an authenticated ordinary display iframe only when its width and height attributes and computed geometry are still 1x1. It resizes that source iframe and every collapsed clipping ancestor through the authenticated slot root to the validated winning dimensions. Ambiguous sources, stale navigation or refresh completions, anchors, interstitials, fixed or sticky frames, invalid dimensions, and already-expanded frames remain unchanged. The same guard applies to APS capabilities, inline `adm`, and PBS Cache responses. In `publisher_native` mode the bridge instead resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response. That renderer replaces the slot through a different owner and does not run the collapsed-shell helper. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index ff5dd3a8b..befdcd427 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -52,11 +52,14 @@ request an existing slot only after it atomically claims an untouched slot. Publisher auction claims use unique, expiring registration tokens. The matching callback moves only its token to delivery-pending and attaches returned ad IDs. -Overlapping auctions cannot clear each other's tokens. If TS claimed first, the GPT -refresh wrapper filters one correlated losing publisher delivery and restores the TS -targeting snapshot. It forwards every unaffected slot and the original refresh options -exactly once. The one-shot state is then consumed, so later publisher refresh auctions -remain eligible. +Overlapping auctions cannot clear each other's tokens. Exact ad-ID delivery consumes +only its matching registration. A code-only delivery consumes a registration only +when exactly one current candidate matches; ambiguous ordinary deliveries run a new +auction, while ambiguous TS-owned suppressing deliveries fail closed without deleting +their tombstones. If TS claimed first, the GPT refresh wrapper filters one correlated +losing publisher delivery and restores the TS targeting snapshot. It forwards every +unaffected slot and the original refresh options exactly once. The one-shot state is +then consumed, so later publisher refresh auctions remain eligible. If a publisher claim expires without a GPT request, `adInit()` retries only that slot after checking the navigation generation, DOM element identity, and ownership again. 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 index 8f751061a..f3603e767 100644 --- 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 @@ -23,8 +23,12 @@ 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. +both still match, and consuming one exact ad-ID delivery removes only its auction's +registration. A code-only delivery consumes a record only when exactly one current +registration matches. Ambiguous ordinary code-only deliveries run an independent +auction rather than guessing; ambiguous TS-owned suppressing deliveries fail closed +without deleting their tombstones. Scoped `requestBids({ adUnitCodes })` calls +inspect, mutate, claim, and correlate only those requested global ad units. ## Refresh suppression @@ -55,9 +59,10 @@ physical element, dropping stale work rather than refreshing a replacement slot. 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. +Validation covers navigation generation, winning bid identity, authenticated +source iframe identity, DOM connectivity, and containment in the authenticated +slot root. When a configured prefix matches several roots, the requesting frame +may disambiguate them only when exactly one candidate root owns that source. 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