From 19c3709ca0d8b5f43eeef6dcdd2fdda0a71d85e1 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 14 Aug 2026 13:35:54 -0500 Subject: [PATCH 1/4] Render APS creatives in opaque data frames --- .../src/integrations/aps.rs | 125 ++-- .../src/response_privacy.rs | 64 ++ ...prebid-universal-creative-1.17.2-banner.js | 4 + .../browser/tests/shared/aps-renderer.spec.ts | 706 +++++++++++++++++- .../trusted-server-js/lib/src/core/request.ts | 3 +- .../lib/src/integrations/aps/render.ts | 401 ++++++++-- .../integrations/aps/renderer-bootstrap.html | 41 + .../integrations/aps/renderer-container.html | 71 ++ .../lib/src/integrations/aps/renderer.html | 228 ++++++ .../lib/src/integrations/gpt/index.ts | 36 +- .../lib/test/core/request.test.ts | 48 +- .../lib/test/integrations/aps/render.test.ts | 334 +++++++-- .../lib/test/integrations/gpt/ad_init.test.ts | 40 +- docs/guide/integrations/aps.md | 33 +- 14 files changed, 1820 insertions(+), 314 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2-banner.js create mode 100644 crates/trusted-server-js/lib/src/integrations/aps/renderer-bootstrap.html create mode 100644 crates/trusted-server-js/lib/src/integrations/aps/renderer-container.html create mode 100644 crates/trusted-server-js/lib/src/integrations/aps/renderer.html diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4fed9278d..6121d6261 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -36,6 +36,7 @@ use crate::settings::{IntegrationConfig, Settings}; const APS_INTEGRATION_ID: &str = "aps"; const APS_RENDERER_ROUTE: &str = "/integrations/aps/renderer"; +const APS_RENDERER_BOOTSTRAP_QUERY: &str = "mode=data-bootstrap"; const DEFAULT_CURRENCY: &str = "USD"; const APS_SDK_SOURCE: &str = "prebid"; const APS_SDK_VERSION: &str = "2.2.0"; @@ -47,72 +48,12 @@ const MAX_LANGUAGE_BYTES: usize = 8; const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;"; +const APS_RENDERER_BOOTSTRAP_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https: data:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;"; -const APS_RENDERER_DOCUMENT: &str = r#" - - -"#; +const APS_RENDERER_DOCUMENT: &str = + include_str!("../../../trusted-server-js/lib/src/integrations/aps/renderer.html"); +const APS_RENDERER_BOOTSTRAP_DOCUMENT: &str = + include_str!("../../../trusted-server-js/lib/src/integrations/aps/renderer-bootstrap.html"); /// Configuration for the APS `OpenRTB` integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -1211,13 +1152,28 @@ impl IntegrationProxy for ApsRendererIntegration { message: "Failed to build APS not-found response".to_string(), }); } + let (renderer_document, renderer_csp) = match request.uri().query() { + None => (APS_RENDERER_DOCUMENT, APS_RENDERER_CSP), + Some(APS_RENDERER_BOOTSTRAP_QUERY) => { + (APS_RENDERER_BOOTSTRAP_DOCUMENT, APS_RENDERER_BOOTSTRAP_CSP) + } + Some(_) => { + return http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("Not Found")) + .change_context(TrustedServerError::Integration { + integration: APS_INTEGRATION_ID.to_string(), + message: "Failed to build APS not-found response".to_string(), + }); + } + }; http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .header("x-content-type-options", "nosniff") .header("referrer-policy", "no-referrer") - .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_CSP) - .body(EdgeBody::from(APS_RENDERER_DOCUMENT)) + .header(header::CONTENT_SECURITY_POLICY, renderer_csp) + .body(EdgeBody::from(renderer_document)) .change_context(TrustedServerError::Integration { integration: APS_INTEGRATION_ID.to_string(), message: "Failed to build APS renderer response".to_string(), @@ -2318,7 +2274,7 @@ mod tests { } #[test] - fn registers_and_serves_only_static_renderer_route() { + fn registers_and_serves_static_renderer_and_data_bootstrap_modes() { let integration = ApsRendererIntegration; let routes = integration.routes(); assert_eq!(routes.len(), 1, "should register one route"); @@ -2347,6 +2303,25 @@ mod tests { APS_RENDERER_CSP ); + let bootstrap = http::Request::builder() + .method(Method::GET) + .uri(format!( + "{APS_RENDERER_ROUTE}?{APS_RENDERER_BOOTSTRAP_QUERY}" + )) + .body(EdgeBody::empty()) + .expect("should build renderer bootstrap request"); + let response = + futures::executor::block_on(integration.handle(&settings, &services, bootstrap)) + .expect("should serve renderer bootstrap"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_SECURITY_POLICY], + APS_RENDERER_BOOTSTRAP_CSP + ); + assert!(APS_RENDERER_BOOTSTRAP_CSP.contains("allow-same-origin")); + assert!(APS_RENDERER_BOOTSTRAP_CSP.contains("frame-src https: data:")); + assert!(APS_RENDERER_BOOTSTRAP_DOCUMENT.contains("trusted-server/aps/bootstrap-navigate")); + let post = http::Request::builder() .method(Method::POST) .uri(APS_RENDERER_ROUTE) @@ -2374,6 +2349,7 @@ mod tests { assert_eq!(registration.integration_id, APS_INTEGRATION_ID); assert_eq!(registration.proxies.len(), 1); + assert!(registration.request_filters.is_empty()); assert!(registration.js_disabled); } @@ -2425,12 +2401,15 @@ mod tests { #[test] fn renderer_document_is_static_and_nonce_bound() { assert!(APS_RENDERER_DOCUMENT.contains("^#tsaps=")); - assert!(APS_RENDERER_DOCUMENT.contains("event.source!==parent")); - assert!(APS_RENDERER_DOCUMENT.contains("message.nonce!==expected")); + assert!(APS_RENDERER_DOCUMENT.contains("event.source !== parent")); + assert!(APS_RENDERER_DOCUMENT.contains("message.nonce !== expected")); + assert!(APS_RENDERER_DOCUMENT.contains("['nonce', 'publisherOrigin', 'renderer']")); + assert!(APS_RENDERER_DOCUMENT.contains("['nonce', 'renderer']")); + assert!(APS_RENDERER_DOCUMENT.contains("url.origin === publisherOrigin")); assert!(APS_RENDERER_DOCUMENT.contains("prebid/creative/render")); assert!(APS_RENDERER_DOCUMENT.contains("window._aps instanceof Map")); - assert!(APS_RENDERER_DOCUMENT.contains("store:new Map([['listeners',new Map()]])")); - assert!(APS_RENDERER_DOCUMENT.contains("account.queue.push(new CustomEvent")); + assert!(APS_RENDERER_DOCUMENT.contains("store: new Map([['listeners', new Map()]])")); + assert!(APS_RENDERER_DOCUMENT.contains("account.queue.push(")); assert!( APS_RENDERER_DOCUMENT.contains("trusted-server/aps/renderer-ready") && APS_RENDERER_DOCUMENT.contains("trusted-server/aps/renderer-failed") @@ -2442,7 +2421,7 @@ mod tests { ); assert!(!APS_RENDERER_DOCUMENT.contains(" diff --git a/crates/trusted-server-js/lib/src/integrations/aps/renderer-container.html b/crates/trusted-server-js/lib/src/integrations/aps/renderer-container.html new file mode 100644 index 000000000..6ef871c9b --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/renderer-container.html @@ -0,0 +1,71 @@ + + + + + + + + diff --git a/crates/trusted-server-js/lib/src/integrations/aps/renderer.html b/crates/trusted-server-js/lib/src/integrations/aps/renderer.html new file mode 100644 index 000000000..b7ddac1c0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/renderer.html @@ -0,0 +1,228 @@ + + + + + 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 f0df35974..e861f8b41 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -10,9 +10,9 @@ import type { import { APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, - apsRendererUrl, consumeApsPrebidRenderer, getApsPrebidRenderer, + registerApsUniversalCreativeMount, validateApsRenderer, } from '../aps/render'; @@ -225,13 +225,14 @@ function slotIdForMessageSource(source: MessageEventSource | null): string | und ?.id; } -function messageSourceBelongsToAdUnit( +function slotRootForMessageSource( source: MessageEventSource | null, - adUnitCode: string -): boolean { - return source - ? sourceIsInSlotRoots(source, candidateSlotRootsForConfiguredDivId(adUnitCode)) - : false; + divId: string +): HTMLElement | undefined { + if (!source) return undefined; + return candidateSlotRootsForConfiguredDivId(divId).find((root) => + sourceIsInSlotRoots(source, [root]) + ); } function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { @@ -1674,13 +1675,15 @@ 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 mountContainer = slotRootForMessageSource(e.source, prebidRendererEntry.adUnitCode); + if (!mountContainer) return; const renderer = validateApsRenderer(prebidRendererEntry.renderer); - const rendererUrl = apsRendererUrl(); - if (!renderer || !rendererUrl) return; + if (!renderer) return; if (!hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return; if (!consumeApsPrebidRenderer(adId, prebidRendererEntry)) return; recordConsumedPrebidApsId(consumedPrebidApsIds, adId, prebidRendererEntry.expiresAt); + const apsMountId = registerApsUniversalCreativeMount(mountContainer, renderer); + if (!apsMountId) return; port.postMessage( JSON.stringify({ @@ -1688,7 +1691,8 @@ export function installTsRenderBridge(): void { adId, renderer: APS_UNIVERSAL_CREATIVE_RENDERER, rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, - rendererUrl, + apsMountId, + publisherOrigin: window.location.origin, apsRenderer: renderer, width: renderer.width, height: renderer.height, @@ -1727,8 +1731,11 @@ export function installTsRenderBridge(): void { e.stopImmediatePropagation(); if (consumedServerApsBySlot.get(slotId) === adId) return; const renderer = validateApsRenderer(matchedBid.renderer); - const rendererUrl = apsRendererUrl(); - if (!renderer || !rendererUrl) return; + const configuredSlot = window.tsjs?.adSlots?.find((slot) => slot.id === slotId); + const mountContainer = slotRootForMessageSource(e.source, configuredSlot?.div_id ?? slotId); + if (!renderer || !mountContainer) return; + const apsMountId = registerApsUniversalCreativeMount(mountContainer, renderer); + if (!apsMountId) return; consumedServerApsBySlot.set(slotId, adId); port.postMessage( JSON.stringify({ @@ -1736,7 +1743,8 @@ export function installTsRenderBridge(): void { adId, renderer: APS_UNIVERSAL_CREATIVE_RENDERER, rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, - rendererUrl, + apsMountId, + publisherOrigin: window.location.origin, apsRenderer: renderer, width: renderer.width, height: renderer.height, diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index dc17c9e87..ba2dfb274 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -69,7 +69,7 @@ describe('request.requestAds', () => { ); }); - it('dispatches a valid APS descriptor to the opaque static renderer route', async () => { + it('dispatches a valid APS descriptor through the opaque data renderer bootstrap', async () => { const apsBid = envelope.seatbid[0].bid[0]; const renderer = { type: 'aps', @@ -117,21 +117,55 @@ describe('request.requestAds', () => { const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; expect(iframe).not.toBeNull(); - expect(iframe!.src).toContain('/integrations/aps/renderer#tsaps='); + expect(iframe!.src).toContain('/integrations/aps/renderer?mode=data-bootstrap#tsaps='); expect(iframe!.srcdoc).toBe(''); expect(iframe!.getAttribute('sandbox')).not.toContain('allow-same-origin'); expect(document.querySelector('#slot1 span')).not.toBeNull(); const postMessage = vi.spyOn(iframe!.contentWindow!, 'postMessage'); - iframe!.dispatchEvent(new Event('load')); + const nonce = new URL(iframe!.src).hash.replace('#tsaps=', ''); + window.dispatchEvent( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/bootstrap-ready', nonce }, + source: iframe!.contentWindow, + }) + ); + const navigate = postMessage.mock.calls[0][0] as { rendererUrl: string }; + const containerDocument = decodeURIComponent( + navigate.rendererUrl + .slice('data:text/html;charset=utf-8,'.length) + .replace(/#tsaps=[A-Za-z0-9_-]{22}$/, '') + ); + const innerNonce = containerDocument.match( + /data:text\/html;charset=utf-8,.+#tsaps=([A-Za-z0-9_-]{22})/ + )?.[1]; + expect(innerNonce).toMatch(/^[A-Za-z0-9_-]{22}$/); + + const channel = { + close: vi.fn(), + onmessage: null, + postMessage: vi.fn(), + start: vi.fn(), + } as unknown as MessagePort; + window.dispatchEvent( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/container-ready', nonce }, + source: iframe!.contentWindow, + ports: [channel], + }) + ); + channel.onmessage?.( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/channel-ready', nonce: innerNonce }, + }) + ); expect(document.querySelector('#slot1 span')).not.toBeNull(); - expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ renderer }), '*'); + expect(channel.postMessage).toHaveBeenCalledWith(expect.objectContaining({ renderer })); - const message = postMessage.mock.calls[0][0] as { nonce: string }; - window.dispatchEvent( + const message = vi.mocked(channel.postMessage).mock.calls[0][0] as { nonce: string }; + channel.onmessage?.( new MessageEvent('message', { data: { message: 'trusted-server/aps/renderer-ready', nonce: message.nonce }, - source: iframe!.contentWindow, }) ); expect(document.querySelector('#slot1 span')).toBeNull(); diff --git a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts index eae60c90e..c9808b8ac 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts @@ -4,14 +4,18 @@ import envelope from '../../fixtures/aps-renderer-v1.json'; import type { ApsRendererV1 } from '../../../src/core/types'; import { log } from '../../../src/core/log'; import { + APS_RENDERER_DATA_URL, APS_RENDERER_PATH, APS_RENDERER_SANDBOX, APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + apsRendererBootstrapUrl, apsRendererUrl, + cancelPendingApsRender, getApsPrebidRenderer, parseApsRendererDescriptor, registerApsPrebidRenderer, + registerApsUniversalCreativeMount, renderApsCreative, validateApsRenderer, } from '../../../src/integrations/aps/render'; @@ -50,6 +54,76 @@ function descriptor(overrides: Partial = {}): ApsRendererV1 { }; } +type FakeRendererChannel = MessagePort & { + close: ReturnType; + postMessage: ReturnType; + start: ReturnType; +}; + +function sendRendererMessage(channel: FakeRendererChannel, data: Record): void { + channel.onmessage?.(new MessageEvent('message', { data })); +} + +function advanceRendererToData(iframe: HTMLIFrameElement) { + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + const nonce = new URL(iframe.src).hash.replace('#tsaps=', ''); + window.dispatchEvent( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/bootstrap-ready', nonce }, + source: iframe.contentWindow, + }) + ); + expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + const navigate = postMessage.mock.calls[0][0] as { + message: string; + nonce: string; + rendererUrl: string; + }; + expect(navigate.message).toBe('trusted-server/aps/bootstrap-navigate'); + expect(navigate.nonce).toBe(nonce); + expect(navigate.rendererUrl).toMatch( + /^data:text\/html;charset=utf-8,.+#tsaps=[A-Za-z0-9_-]{22}$/ + ); + + const encodedDocument = navigate.rendererUrl + .slice('data:text/html;charset=utf-8,'.length) + .replace(/#tsaps=[A-Za-z0-9_-]{22}$/, ''); + const containerDocument = decodeURIComponent(encodedDocument); + const innerNonce = containerDocument.match( + /data:text\/html;charset=utf-8,.+#tsaps=([A-Za-z0-9_-]{22})/ + )?.[1]; + expect(innerNonce).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(innerNonce).not.toBe(nonce); + expect(containerDocument).toContain('frame-src data: https://creative.example'); + expect(containerDocument).not.toContain(descriptor().bidId); + expect(containerDocument).not.toContain(descriptor().aaxResponse); + + const channel = { + close: vi.fn(), + onmessage: null, + postMessage: vi.fn(), + start: vi.fn(), + } as unknown as FakeRendererChannel; + window.dispatchEvent( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/container-ready', nonce }, + source: iframe.contentWindow, + ports: [channel], + }) + ); + expect(channel.start).toHaveBeenCalledOnce(); + sendRendererMessage(channel, { + message: 'trusted-server/aps/channel-ready', + nonce: innerNonce, + }); + const sent = channel.postMessage.mock.calls[0][0] as { + nonce: string; + publisherOrigin: string; + renderer: ApsRendererV1; + }; + return { channel, innerNonce: innerNonce!, postMessage, sent }; +} + describe('APS renderer validation', () => { it('consumes the shared fictional golden envelope and supports an omitted creative ID', () => { const withCreativeId = descriptor(); @@ -271,62 +345,58 @@ describe('direct APS rendering', () => { document.body.innerHTML = ''; }); - it('loads the static route with a fragment-bound 128-bit nonce and opaque sandbox', () => { + it('bootstraps the data renderer with a fragment-bound 128-bit nonce', () => { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); const slot = document.getElementById('fictional-slot')!; const iframe = slot.querySelector('iframe')!; const existing = slot.querySelector('span'); expect(existing).not.toBeNull(); - expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); - expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(iframe.src.startsWith(`${apsRendererBootstrapUrl()}#tsaps=`)).toBe(true); + expect(iframe.src).toMatch(/\?mode=data-bootstrap#tsaps=[A-Za-z0-9_-]{22}$/); expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); expect(iframe.srcdoc).toBe(''); - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - + const { channel, sent } = advanceRendererToData(iframe); expect(slot.querySelector('span')).not.toBeNull(); expect(iframe.style.display).toBe('none'); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledWith( - { - nonce: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), - renderer: descriptor(), - }, - '*' - ); + expect(iframe.getAttribute('sandbox')).toContain('allow-same-origin'); + expect(sent).toEqual({ + nonce: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), + publisherOrigin: window.location.origin, + renderer: descriptor(), + }); + + sendRendererMessage(channel, { + message: 'trusted-server/aps/renderer-ready', + nonce: `wrong-${sent.nonce}`, + }); + expect(slot.querySelector('span')).not.toBeNull(); - const message = postMessage.mock.calls[0][0] as { nonce: string }; window.dispatchEvent( new MessageEvent('message', { - data: { - message: 'trusted-server/aps/renderer-ready', - nonce: `wrong-${message.nonce}`, - }, + data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, source: iframe.contentWindow, }) ); expect(slot.querySelector('span')).not.toBeNull(); + expect(iframe.style.display).toBe('none'); - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: message.nonce }, - source: iframe.contentWindow, - }) - ); + sendRendererMessage(channel, { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + }); + expect(iframe.getAttribute('sandbox')).toContain('allow-same-origin'); expect(slot.querySelector('span')).toBeNull(); expect(iframe.style.display).toBe(''); }); - it('rejects a ready message with the correct nonce from a foreign window', () => { + it('accepts readiness only through the transferred renderer channel', () => { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); const slot = document.getElementById('fictional-slot')!; const rendererFrame = slot.querySelector('iframe')!; - const postMessage = vi.spyOn(rendererFrame.contentWindow!, 'postMessage'); - rendererFrame.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string }; + const { channel, sent } = advanceRendererToData(rendererFrame); const foreignFrame = document.createElement('iframe'); document.body.appendChild(foreignFrame); @@ -336,10 +406,6 @@ describe('direct APS rendering', () => { source: foreignFrame.contentWindow, }) ); - - expect(slot.querySelector('span')).not.toBeNull(); - expect(rendererFrame.style.display).toBe('none'); - window.dispatchEvent( new MessageEvent('message', { data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, @@ -347,6 +413,13 @@ describe('direct APS rendering', () => { }) ); + expect(slot.querySelector('span')).not.toBeNull(); + expect(rendererFrame.style.display).toBe('none'); + + sendRendererMessage(channel, { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + }); expect(slot.querySelector('span')).toBeNull(); expect(rendererFrame.style.display).toBe(''); }); @@ -368,6 +441,16 @@ describe('direct APS rendering', () => { expect(document.querySelector('#fictional-slot iframe')).toBeNull(); }); + it('cancels a pending frame before another renderer replaces the slot', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const container = document.getElementById('fictional-slot')!; + + cancelPendingApsRender(container); + + expect(container.querySelector('span')).not.toBeNull(); + expect(container.querySelector('iframe')).toBeNull(); + }); + it('removes an unacknowledged frame without clearing publisher content', () => { vi.useFakeTimers(); try { @@ -391,9 +474,9 @@ describe('direct APS rendering', () => { const baselineTimers = vi.getTimerCount(); expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); const firstFrame = document.querySelector('#fictional-slot iframe')!; - const firstPostMessage = vi.spyOn(firstFrame.contentWindow!, 'postMessage'); - firstFrame.dispatchEvent(new Event('load')); - const firstSent = firstPostMessage.mock.calls[0][0] as { nonce: string }; + const { channel: firstChannel, sent: firstSent } = advanceRendererToData( + firstFrame as HTMLIFrameElement + ); const timersAfterFirst = vi.getTimerCount(); expect(timersAfterFirst).toBeGreaterThan(baselineTimers); @@ -401,25 +484,21 @@ describe('direct APS rendering', () => { const secondFrame = document.querySelector('#fictional-slot iframe')!; expect(firstFrame.isConnected).toBe(false); expect(vi.getTimerCount()).toBe(timersAfterFirst); - const postMessage = vi.spyOn(secondFrame.contentWindow!, 'postMessage'); - secondFrame.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string }; - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: firstSent.nonce }, - source: firstFrame.contentWindow, - }) + const { channel: secondChannel, sent } = advanceRendererToData( + secondFrame as HTMLIFrameElement ); + + sendRendererMessage(firstChannel, { + message: 'trusted-server/aps/renderer-ready', + nonce: firstSent.nonce, + }); expect(document.querySelector('#fictional-slot span')).not.toBeNull(); expect((secondFrame as HTMLIFrameElement).style.display).toBe('none'); - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: secondFrame.contentWindow, - }) - ); + sendRendererMessage(secondChannel, { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + }); vi.advanceTimersByTime(10_000); expect(warnSpy).not.toHaveBeenCalled(); @@ -430,19 +509,21 @@ describe('direct APS rendering', () => { }); describe('Universal Creative APS source', () => { - it('uses the deployed dynamic renderer protocol and only creates the opaque route frame', () => { - expect(APS_UNIVERSAL_CREATIVE_RENDERER_VERSION).toBeGreaterThanOrEqual(4); + it('uses the deployed dynamic renderer protocol to request a top-page mount', () => { + expect(APS_UNIVERSAL_CREATIVE_RENDERER_VERSION).toBeGreaterThanOrEqual(6); expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('window.render=function'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.apsRenderer'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.rendererUrl'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain(APS_RENDERER_PATH); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain(APS_RENDERER_SANDBOX); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('allow-same-origin'); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.apsMountId'); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.publisherOrigin'); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('trusted-server/aps/mount-request'); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('d&&d.apsRenderer'); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('d&&d.rendererUrl'); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain(APS_RENDERER_DATA_URL); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain(APS_RENDERER_SANDBOX); expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('srcdoc'); expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('document.write'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('creativeUrl'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('aaxResponse'); expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('example-account-id'); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain(descriptor().creativeUrl); + expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain(descriptor().aaxResponse); }); it('computes an absolute renderer URL from the publisher origin', () => { @@ -453,31 +534,114 @@ describe('Universal Creative APS source', () => { expect(apsRendererUrl('not an origin')).toBeUndefined(); }); - it('creates the opaque route frame and resolves only after the bound acknowledgement', async () => { + it('consumes a top-page mount capability once and preserves the controller frame', () => { + document.body.innerHTML = + '
'; + const container = document.getElementById('fictional-puc-slot')!; + const controller = container.querySelector('.puc-controller')!; + const requester = document.createElement('iframe'); + document.body.appendChild(requester); + const resultPost = vi.spyOn(requester.contentWindow!, 'postMessage'); + const mountId = registerApsUniversalCreativeMount(container, descriptor())!; + const requestNonce = 'ZYXWVUTSRQPONMLKJIHGFE'; + + const request = () => + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/mount-request', + mountId, + nonce: requestNonce, + }, + source: requester.contentWindow, + }) + ); + request(); + request(); + + const rendererFrame = container.querySelector( + 'iframe[data-ts-aps-renderer="true"]' + )!; + expect(rendererFrame).not.toBeNull(); + expect(container.querySelectorAll('iframe[data-ts-aps-renderer="true"]')).toHaveLength(1); + expect(controller.isConnected).toBe(true); + + const { channel, sent } = advanceRendererToData(rendererFrame); + sendRendererMessage(channel, { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + }); + + expect(controller.isConnected).toBe(true); + expect(controller.style.display).toBe('none'); + expect(rendererFrame.style.display).toBe(''); + expect(resultPost).toHaveBeenCalledWith( + { + message: 'trusted-server/aps/mount-result', + mountId, + nonce: requestNonce, + status: 'ready', + }, + '*' + ); + document.body.innerHTML = ''; + }); + + it('revokes an older mount capability when the same container is registered again', () => { + document.body.innerHTML = '
'; + const container = document.getElementById('fictional-refresh-slot')!; + const requester = document.createElement('iframe'); + document.body.appendChild(requester); + const oldMountId = registerApsUniversalCreativeMount(container, descriptor())!; + const newMountId = registerApsUniversalCreativeMount(container, descriptor())!; + const request = (mountId: string) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/mount-request', + mountId, + nonce: 'ABCDEFGHIJKLMNOPQRSTUV', + }, + source: requester.contentWindow, + }) + ); + + request(oldMountId); + expect(container.querySelector('iframe[data-ts-aps-renderer="true"]')).toBeNull(); + request(newMountId); + const rendererFrame = container.querySelector( + 'iframe[data-ts-aps-renderer="true"]' + ); + expect(rendererFrame).not.toBeNull(); + rendererFrame!.dispatchEvent(new Event('error')); + document.body.innerHTML = ''; + }); + + it('resolves only after the top page acknowledges its one-shot mount request', async () => { const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; + render?: (data: Record) => Promise; }; + const postMessage = vi.spyOn(window.top, 'postMessage'); window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); try { - const renderer = descriptor(); - const rendered = dynamicWindow.render!( - { - apsRenderer: renderer, - rendererUrl: apsRendererUrl(), - }, - undefined, - window - ); - const iframe = document.body.querySelector('iframe')!; - expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); - expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); - expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); - - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string; renderer: ApsRendererV1 }; - expect(sent.renderer).toEqual(renderer); + const mountId = 'ABCDEFGHIJKLMNOPQRSTUV'; + const rendered = dynamicWindow.render!({ + apsMountId: mountId, + publisherOrigin: window.location.origin, + }); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(postMessage).toHaveBeenCalledTimes(1); + const sent = postMessage.mock.calls[0][0] as { + message: string; + mountId: string; + nonce: string; + }; + expect(sent).toEqual({ + message: 'trusted-server/aps/mount-request', + mountId, + nonce: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), + }); let settled = false; void rendered.then(() => { @@ -488,12 +652,18 @@ describe('Universal Creative APS source', () => { window.dispatchEvent( new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: iframe.contentWindow, + data: { + message: 'trusted-server/aps/mount-result', + mountId, + nonce: sent.nonce, + status: 'ready', + }, + source: window.top, }) ); await expect(rendered).resolves.toBeUndefined(); } finally { + postMessage.mockRestore(); delete dynamicWindow.render; document.body.innerHTML = ''; } 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 179a810d5..6d73094cf 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 @@ -3143,11 +3143,12 @@ describe('installTsRenderBridge', () => { expect(Object.keys(response).sort()).toEqual( [ 'adId', + 'apsMountId', 'apsRenderer', 'height', 'message', + 'publisherOrigin', 'renderer', - 'rendererUrl', 'rendererVersion', 'width', ].sort() @@ -3156,8 +3157,9 @@ describe('installTsRenderBridge', () => { message: 'Prebid Response', adId: renderer.bidId, renderer: expect.stringContaining('window.render=function'), - rendererVersion: 4, - rendererUrl: new URL('/integrations/aps/renderer', window.location.origin).href, + rendererVersion: 6, + apsMountId: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), + publisherOrigin: window.location.origin, apsRenderer: renderer, width: 300, height: 250, @@ -3165,35 +3167,9 @@ describe('installTsRenderBridge', () => { expect(String(response.renderer)).not.toContain(renderer.accountId); expect(String(response.renderer)).not.toContain(renderer.aaxResponse); - // Universal Creative's dynamic-renderer path evaluates the returned static - // source and calls window.render(response, helper, targetWindow). Consume - // the exact bridge response through that deployed protocol shape. - const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; - }; - window.eval(String(response.renderer)); - try { - const rendered = dynamicWindow.render!(response, undefined, window); - const outerFrame = document.querySelector( - 'iframe[src*="/integrations/aps/renderer#tsaps="]' - )!; - expect(outerFrame).not.toBeNull(); - expect(outerFrame.getAttribute('sandbox')).not.toContain('allow-same-origin'); - - const rendererPost = vi.spyOn(outerFrame.contentWindow!, 'postMessage'); - outerFrame.dispatchEvent(new Event('load')); - const sent = rendererPost.mock.calls[0][0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: outerFrame.contentWindow, - }) - ); - await expect(rendered).resolves.toBeUndefined(); - outerFrame.remove(); - } finally { - delete dynamicWindow.render; - } + expect(String(response.renderer)).toContain('d&&d.apsMountId'); + expect(String(response.renderer)).not.toContain(renderer.creativeUrl); + expect(String(response.renderer)).not.toContain(renderer.aaxResponse); beaconSpy.mockRestore(); }); diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 658f5d5bc..3ab6e904b 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -150,30 +150,27 @@ Trusted Server does not insert APS creative markup into the publisher document. Seats, `impid`, markup, notifications, user-sync data, sibling bids, losing seats, and unknown fields are not exposed. The browser decodes this envelope and cross-checks the ID, dimensions, URL, and tag type before any DOM mutation or message suppression. -Both rendering paths use `GET /integrations/aps/renderer`, a static Trusted Server document with its own restrictive CSP. The document initializes the account-keyed APS queue and then loads only the fixed runner at `https://client.aps.amazon-adsystem.com/prebid-creative.js`. +Both rendering paths use a publisher-origin bootstrap followed by two nested `data:` documents. TSJS first loads `GET /integrations/aps/renderer?mode=data-bootstrap` with a sandbox that omits `allow-same-origin`. The bootstrap is therefore opaque and cannot read or modify the publisher document. After a nonce-bound readiness message, TSJS adds `allow-same-origin` and asks the bootstrap to navigate itself to a per-impression `data:` container. The container then creates the static inner `data:` renderer under the same permanent sandbox. -The outer iframe uses these sandbox permissions: +Both data documents are naturally opaque even with `allow-same-origin`, so the publisher cannot access them. Keeping that token on both frames also avoids WebKit propagating an opaque origin to the HTTPS creative: the creative and its same-origin descendants retain their real origin in Chromium, Firefox, and WebKit. -```text -allow-forms -allow-pointer-lock -allow-popups -allow-popups-to-escape-sandbox -allow-scripts -allow-top-navigation-by-user-activation -``` +The outer container's CSP allows child frames from `data:` and only the fully validated creative URL's exact origin. That policy is inherited by the inner data renderer and intersects with the renderer's own CSP. It permits the expected creative frame but blocks the inner renderer from navigating itself to the publisher origin before a request is made. This exact-origin boundary intentionally blocks an immediate creative-frame redirect or same-frame navigation to a different origin; validate real APS inventory for intermediate or redirect origins before rollout. User-activated top navigation and popups remain governed by the sandbox tokens. + +A network-loaded HTTPS creative does not inherit its ancestor's CSP and can create further frames. To prevent it from using a publisher-origin descendant plus an executable publisher gadget to regain access to the top page, enabling APS appends a separate `Content-Security-Policy: frame-ancestors 'self'` policy to every Trusted Server response. Browsers require every ancestor to match, so publisher documents cannot load below the opaque container or third-party creative. This secure default also prevents the APS-enabled publisher from being embedded cross-origin; publishers that require trusted external embedders need a separately reviewed ancestor-allowlist feature before enabling APS. + +Trusted Server generates independent 128-bit nonces for the container and renderer. Once the inner renderer is ready, the container transfers a dedicated `MessagePort` between trusted top-page TSJS and the inner renderer. The complete descriptor travels only over that port; the inner renderer revalidates it, rejects the publisher origin, and requires the creative origin to equal the origin bound in the container CSP. The container URL contains only that exact origin, static renderer source, and nonces—never the response envelope, bid ID, price, or creative URL path. -It deliberately omits `allow-same-origin`, so APS and bidder execution remains below an opaque-origin boundary. The renderer response repeats these restrictions with a CSP `sandbox` directive, preventing another embedding path from restoring publisher-origin execution by omitting the iframe attribute. Trusted Server generates a fresh 128-bit nonce, binds it in the iframe URL fragment before navigation, and requires the same one-time nonce in the parent message and renderer acknowledgement. Existing slot content is retained until the static renderer has accepted the descriptor and loaded the fixed runner. +The inner document initializes the account-keyed APS queue and loads only the fixed runner at `https://client.aps.amazon-adsystem.com/prebid-creative.js`. A `renderer-ready` acknowledgement still means that Amazon's runner loaded, not that the final creative painted. Existing slot content remains until that acknowledgement. The query-free `GET /integrations/aps/renderer` response remains available with its original stricter CSP and legacy message shape for cached clients and rollback. ### Direct `/auction` -The TSJS auction client validates the typed renderer descriptor, creates the opaque renderer iframe, and sends the minimized envelope after the frame loads. Ordinary non-APS `adm` continues through the existing sanitizer and generic creative iframe. +The TSJS auction client validates the typed renderer descriptor and mounts the nested data renderer in the winning slot. Ordinary non-APS `adm` continues through the existing sanitizer and generic creative iframe. ### 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`, validates the complete envelope, and returns a static dynamic-renderer program that creates the same opaque renderer iframe. +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`, validates the complete envelope, and returns a static dynamic-renderer program with a one-shot mount capability. That program asks trusted top-page TSJS to mount the nested data renderer as a sibling of the Universal Creative iframe, outside inherited GAM and Universal Creative sandbox restrictions. -For client-side `trustedServer` adapter auctions, Prebid generates its own `hb_adid`. Trusted Server binds that generated ID to the validated APS descriptor in a bounded, expiring browser registry before GAM refresh. The bridge verifies that the requesting Universal Creative iframe belongs to the same ad unit, consumes the capability once, and passes the APS bid ID separately to the Amazon runner. +For client-side `trustedServer` adapter auctions, Prebid generates its own `hb_adid`. Trusted Server binds that generated ID to the validated APS descriptor in a bounded, expiring browser registry before GAM refresh. The bridge verifies that the requesting Universal Creative iframe belongs to the same ad unit, consumes both the renderer and mount capabilities once, and passes the APS bid ID separately to the Amazon runner. These paths do not fetch PBS Cache, fire generic APS win/billing beacons, or call `apstag.setDisplayBids()` for the Trusted Server winner. Publisher-owned native APS objects are otherwise left untouched. @@ -182,10 +179,10 @@ These paths do not fetch PBS Cache, fire generic APS win/billing beacons, or cal The publisher policy must permit the same-origin renderer route, for example: ```text -frame-src 'self' +frame-src 'self' data: ``` -Do not add `allow-same-origin` to the outer renderer sandbox. The renderer endpoint supplies its own CSP for the fixed runner and HTTPS creative resources. The same-origin renderer route inherits the publisher page scheme; use HTTPS in production. APS endpoints and third-party creative URLs always require HTTPS. +The `data:` source is required for the bootstrap's self-navigation to the opaque container. APS also adds `frame-ancestors 'self'` as an independent response policy; do not override or remove that policy. Do not weaken or bypass the initial bootstrap sandbox. TSJS adds `allow-same-origin` only when navigating away from the publisher-origin bootstrap; both final data documents remain naturally opaque. The bootstrap response supplies the resource CSP for the fixed runner and HTTPS creative resources, while the container narrows `frame-src` to the validated creative origin. The same-origin bootstrap route inherits the publisher page scheme; use HTTPS in production. APS endpoints and third-party creative URLs always require HTTPS. Before enabling script creatives, verify under the publisher's actual CSP that both iframe and script-tag creatives: @@ -235,8 +232,8 @@ Use fictional values in source-controlled configuration and fixtures. Supply con ### Winner targets but does not render -- Confirm `GET /integrations/aps/renderer` returns HTML with its CSP and `Referrer-Policy: no-referrer`. -- Confirm publisher CSP permits `frame-src 'self'`. +- Confirm `GET /integrations/aps/renderer?mode=data-bootstrap` returns HTML with its bootstrap CSP and `Referrer-Policy: no-referrer`. +- Confirm publisher CSP permits `frame-src 'self' data:`. - Confirm the GAM creative uses the supported Prebid Universal Creative bridge and the winning `hb_adid`. - For client-side `trustedServer` adapter auctions, confirm Prebid's `bidResponse` contains a generated `adId` and that the corresponding capability appears briefly in `window.tsjs.apsPrebidRenderers` before rendering. - Ensure no native APS path is trying to handle the same cohort. From 0fad2cde29e86a0aebec8eb77f09a3d0caaeffe5 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 18 Aug 2026 14:04:54 -0500 Subject: [PATCH 2/4] Restore APS renderer lifecycle on GPT refresh --- .github/workflows/integration-tests.yml | 29 ++- .../browser/playwright.config.ts | 10 +- .../lib/src/integrations/aps/render.ts | 75 ++++++-- .../lib/src/integrations/gpt/index.ts | 39 +++- .../lib/test/integrations/aps/render.test.ts | 175 +++++++++++++++++- .../lib/test/integrations/gpt/ad_init.test.ts | 74 ++++++++ docs/guide/integrations/aps.md | 4 +- scripts/integration-tests-browser.sh | 2 +- 8 files changed, 387 insertions(+), 21 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4973afe44..da40ba7da 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -198,7 +198,7 @@ jobs: working-directory: crates/trusted-server-integration-tests/browser run: | npm ci - npx playwright install --with-deps chromium + npx playwright install --with-deps chromium firefox webkit - name: Run browser tests (Next.js) working-directory: crates/trusted-server-integration-tests/browser @@ -208,7 +208,7 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml TEST_FRAMEWORK: nextjs PLAYWRIGHT_HTML_REPORT: playwright-report-nextjs - run: npx playwright test + run: npx playwright test --project=chromium - name: Upload Playwright report (Next.js) uses: actions/upload-artifact@v4 @@ -218,6 +218,29 @@ jobs: path: crates/trusted-server-integration-tests/browser/playwright-report-nextjs/ retention-days: 7 + - name: Run APS browser tests (Next.js, Firefox and WebKit) + working-directory: crates/trusted-server-integration-tests/browser + env: + WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + TEST_FRAMEWORK: nextjs + PLAYWRIGHT_HTML_REPORT: playwright-report-aps-cross-browser + PLAYWRIGHT_OUTPUT_DIR: test-results-aps-cross-browser + run: >- + npx playwright test tests/shared/aps-renderer.spec.ts + --project=firefox --project=webkit + + - name: Upload APS cross-browser Playwright artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-aps-cross-browser + path: | + crates/trusted-server-integration-tests/browser/playwright-report-aps-cross-browser/ + crates/trusted-server-integration-tests/browser/test-results-aps-cross-browser/ + retention-days: 7 + - name: Run browser tests (WordPress) if: always() working-directory: crates/trusted-server-integration-tests/browser @@ -227,7 +250,7 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml TEST_FRAMEWORK: wordpress PLAYWRIGHT_HTML_REPORT: playwright-report-wordpress - run: npx playwright test + run: npx playwright test --project=chromium - name: Upload Playwright report (WordPress) uses: actions/upload-artifact@v4 diff --git a/crates/trusted-server-integration-tests/browser/playwright.config.ts b/crates/trusted-server-integration-tests/browser/playwright.config.ts index 8a1ef3b5b..f52e43364 100644 --- a/crates/trusted-server-integration-tests/browser/playwright.config.ts +++ b/crates/trusted-server-integration-tests/browser/playwright.config.ts @@ -18,7 +18,15 @@ export default defineConfig({ name: "chromium", use: { browserName: "chromium" }, }, + { + name: "firefox", + use: { browserName: "firefox" }, + }, + { + name: "webkit", + use: { browserName: "webkit" }, + }, ], reporter: [["list"], ["html", { open: "never" }]], - outputDir: "./test-results", + outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR ?? "./test-results", }); 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 e1f0e0526..b0029dd4b 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -33,6 +33,14 @@ const DESCRIPTOR_KEYS = [ const DESCRIPTOR_KEYS_WITH_CREATIVE_ID = [...DESCRIPTOR_KEYS, 'creativeId'].sort(); const activeFrames = new WeakMap(); const pendingFrameCancels = new WeakMap void>(); +type CommittedApsMount = { + frame: HTMLIFrameElement; + hiddenSiblings: Array<{ + element: HTMLElement; + display: { value: string; priority: string }; + }>; +}; +const committedApsMounts = new WeakMap(); const RENDERER_READY_MESSAGE = 'trusted-server/aps/renderer-ready'; const RENDERER_FAILED_MESSAGE = 'trusted-server/aps/renderer-failed'; const RENDERER_READY_TIMEOUT_MS = 10_000; @@ -375,9 +383,10 @@ function mountApsRendererFrame( pendingFrameCancels.get(container)?.(); activeFrames.set(container, iframe); - let phase: 'active' | 'bootstrap' | 'channel' | 'legacy' | 'renderer' = 'bootstrap'; + let phase: 'active' | 'bootstrap' | 'channel' | 'renderer' = 'bootstrap'; let rendererChannel: MessagePort | undefined; let settled = false; + let legacyDescriptorPosted = false; let legacyFallbackId: number | undefined; const cleanup = (): void => { window.removeEventListener('message', receive); @@ -406,6 +415,7 @@ function mountApsRendererFrame( cleanup(); if (pendingFrameCancels.get(container) === cancel) pendingFrameCancels.delete(container); + const hiddenSiblings: CommittedApsMount['hiddenSiblings'] = []; for (const child of Array.from(container.children)) { if (child === iframe) continue; if (options.mode === 'replace' || (child as HTMLElement).dataset.tsApsRenderer === 'true') { @@ -413,9 +423,17 @@ function mountApsRendererFrame( } else if (child instanceof HTMLElement) { // Keep Universal Creative connected long enough to receive the success // acknowledgement, but never leave two visible rendering surfaces. - child.style.display = 'none'; + hiddenSiblings.push({ + element: child, + display: { + value: child.style.getPropertyValue('display'), + priority: child.style.getPropertyPriority('display'), + }, + }); + child.style.setProperty('display', 'none', 'important'); } } + committedApsMounts.set(container, { frame: iframe, hiddenSiblings }); iframe.style.display = ''; options.onReady?.(); }; @@ -425,7 +443,11 @@ function mountApsRendererFrame( fail(); return; } - target.postMessage({ nonce, publisherOrigin, renderer }, '*'); + // The query-free renderer retained for rollback accepts this exact legacy + // shape. Keep the modern bootstrap phase live in case its ready message is + // delayed past the compatibility timer. + legacyDescriptorPosted = true; + target.postMessage({ nonce, renderer }, '*'); }; function receiveChannel(event: MessageEvent): void { if (!hasExactKeys(event.data, ['message', 'nonce']) || event.data.nonce !== innerNonce) return; @@ -460,7 +482,11 @@ function mountApsRendererFrame( rendererChannel = event.ports[0]; rendererChannel.onmessage = receiveChannel; rendererChannel.start(); - } else if (event.data.message === RENDERER_READY_MESSAGE && phase === 'legacy') { + } else if ( + event.data.message === RENDERER_READY_MESSAGE && + phase === 'bootstrap' && + legacyDescriptorPosted + ) { commit(); } else if (event.data.message === RENDERER_FAILED_MESSAGE) { fail(); @@ -469,12 +495,9 @@ function mountApsRendererFrame( function load(): void { if (settled || activeFrames.get(container) !== iframe || !iframe.isConnected) return; try { - if (phase === 'legacy') { - postLegacyDescriptor(); - } else if (phase === 'bootstrap') { + if (phase === 'bootstrap' && legacyFallbackId === undefined) { legacyFallbackId = window.setTimeout(() => { if (phase !== 'bootstrap' || settled) return; - phase = 'legacy'; postLegacyDescriptor(); }, LEGACY_RENDERER_FALLBACK_MS); } @@ -493,9 +516,30 @@ function mountApsRendererFrame( return true; } -/** Cancel pending APS work before another renderer replaces this container. */ +/** Cancel pending or committed APS work before another renderer replaces this container. */ export function cancelPendingApsRender(container: HTMLElement): void { pendingFrameCancels.get(container)?.(); + + const committed = committedApsMounts.get(container); + if (committed) { + committedApsMounts.delete(container); + if (activeFrames.get(container) === committed.frame) activeFrames.delete(container); + committed.frame.remove(); + for (const sibling of committed.hiddenSiblings) { + if (sibling.element.parentElement === container) { + if (sibling.display.value) { + sibling.element.style.setProperty( + 'display', + sibling.display.value, + sibling.display.priority + ); + } else { + sibling.element.style.removeProperty('display'); + } + } + } + } + for (const [mountId, entry] of pendingUniversalMounts) { if (entry.container === container) pendingUniversalMounts.delete(mountId); } @@ -526,11 +570,20 @@ export function registerApsUniversalCreativeMount( const now = Date.now(); prunePendingUniversalMounts(now); - cancelPendingApsRender(container); - if (pendingUniversalMounts.size >= MAX_PENDING_UNIVERSAL_MOUNTS) return undefined; + // Validate every synchronous failure before replacing an already committed + // creative. A successful re-registration still revokes this container's old + // pending capability, but capacity consumed by other slots must not fork it. const mountId = createNonce(); if (!mountId || pendingUniversalMounts.has(mountId)) return undefined; + const pendingForContainer = Array.from(pendingUniversalMounts.values()).filter( + (entry) => entry.container === container + ).length; + if (pendingUniversalMounts.size - pendingForContainer >= MAX_PENDING_UNIVERSAL_MOUNTS) { + return undefined; + } + + cancelPendingApsRender(container); pendingUniversalMounts.set(mountId, { container, expiresAt: now + RENDERER_READY_TIMEOUT_MS, 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 e861f8b41..10b0a2f61 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -10,6 +10,7 @@ import type { import { APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + cancelPendingApsRender, consumeApsPrebidRenderer, getApsPrebidRenderer, registerApsUniversalCreativeMount, @@ -77,11 +78,14 @@ interface GoogleTagSlot { getTargeting?(key: string): string[]; } -interface SlotRenderEndedEvent { - isEmpty: boolean; +interface SlotRequestedEvent { slot: GoogleTagSlot; } +interface SlotRenderEndedEvent extends SlotRequestedEvent { + isEmpty: boolean; +} + interface SlotElementResolution { element: HTMLElement | null; prefixMatchCount: number; @@ -251,7 +255,8 @@ interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; - addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; + addEventListener(event: 'slotRequested', fn: (e: SlotRequestedEvent) => void): void; + addEventListener(event: 'slotRenderEnded', fn: (e: SlotRenderEndedEvent) => void): void; refresh(slots?: GoogleTagSlot[], options?: GoogleTagRefreshOptions): void; getSlots?(): GoogleTagSlot[]; disableInitialLoad?(): void; @@ -395,6 +400,7 @@ export function installGptShim(): boolean { const tag = ensureGoogleTagStub(win); patchCommandQueue(tag); + installApsRefreshTeardownListener(); log.info('GPT shim installed'); return true; @@ -1604,6 +1610,31 @@ function recordConsumedPrebidApsId( consumedIds.set(adId, { expiresAt }); } +let apsRefreshTeardownListenerInstalled = false; +let apsRefreshTeardownCommandQueued = false; + +function installApsRefreshTeardownListener(): void { + if (apsRefreshTeardownListenerInstalled || apsRefreshTeardownCommandQueued) return; + const googletag = (window as GptWindow).googletag; + if (!googletag?.cmd) return; + + apsRefreshTeardownCommandQueued = true; + googletag.cmd.push(() => { + apsRefreshTeardownCommandQueued = false; + if (apsRefreshTeardownListenerInstalled || typeof googletag.pubads !== 'function') return; + const pubads = googletag.pubads(); + if (typeof pubads.addEventListener !== 'function') return; + pubads.addEventListener('slotRequested', (event: SlotRequestedEvent) => { + const elementId = event.slot?.getSlotElementId?.(); + if (!elementId) return; + for (const container of candidateSlotRoots(elementId)) { + cancelPendingApsRender(container); + } + }); + apsRefreshTeardownListenerInstalled = true; + }); +} + /** * Install the TS → pbRender bridge. * @@ -1627,6 +1658,8 @@ function recordConsumedPrebidApsId( export function installTsRenderBridge(): void { if (typeof window === 'undefined') return; + installApsRefreshTeardownListener(); + // `slotId|adId` renders whose PBS Cache fetch is in flight. `fireWinBillingBeacons` // only dedups after the async fetch resolves, so two Prebid Request messages for // the same render arriving before the first fetch settles would both fetch and diff --git a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts index c9808b8ac..51cbab321 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts @@ -391,6 +391,74 @@ describe('direct APS rendering', () => { expect(iframe.style.display).toBe(''); }); + it('continues the modern bootstrap when ready arrives after the legacy fallback', () => { + vi.useFakeTimers(); + try { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const slot = document.getElementById('fictional-slot')!; + const iframe = slot.querySelector('iframe')!; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + const nonce = new URL(iframe.src).hash.replace('#tsaps=', ''); + + iframe.dispatchEvent(new Event('load')); + vi.advanceTimersByTime(100); + + expect(postMessage).toHaveBeenCalledWith({ nonce, renderer: descriptor() }, '*'); + window.dispatchEvent( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/bootstrap-ready', nonce }, + source: iframe.contentWindow, + }) + ); + + const navigate = postMessage.mock.calls.find( + ([message]) => + (message as { message?: string }).message === 'trusted-server/aps/bootstrap-navigate' + )?.[0] as { rendererUrl: string } | undefined; + expect(navigate?.rendererUrl).toMatch( + /^data:text\/html;charset=utf-8,.+#tsaps=[A-Za-z0-9_-]{22}$/ + ); + expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + } finally { + const slot = document.getElementById('fictional-slot'); + if (slot) cancelPendingApsRender(slot); + vi.useRealTimers(); + } + }); + + it('accepts the exact two-key descriptor fallback for a legacy renderer', () => { + vi.useFakeTimers(); + try { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const slot = document.getElementById('fictional-slot')!; + const iframe = slot.querySelector('iframe')!; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + const nonce = new URL(iframe.src).hash.replace('#tsaps=', ''); + + iframe.dispatchEvent(new Event('load')); + vi.advanceTimersByTime(100); + + expect(postMessage).toHaveBeenCalledOnce(); + expect(postMessage).toHaveBeenCalledWith({ nonce, renderer: descriptor() }, '*'); + expect(Object.keys(postMessage.mock.calls[0]![0] as object).sort()).toEqual([ + 'nonce', + 'renderer', + ]); + + window.dispatchEvent( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/renderer-ready', nonce }, + source: iframe.contentWindow, + }) + ); + + expect(slot.querySelector('span')).toBeNull(); + expect(iframe.style.display).toBe(''); + } finally { + vi.useRealTimers(); + } + }); + it('accepts readiness only through the transferred renderer channel', () => { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); @@ -539,6 +607,7 @@ describe('Universal Creative APS source', () => { '
'; const container = document.getElementById('fictional-puc-slot')!; const controller = container.querySelector('.puc-controller')!; + controller.style.setProperty('display', 'inline-block', 'important'); const requester = document.createElement('iframe'); document.body.appendChild(requester); const resultPost = vi.spyOn(requester.contentWindow!, 'postMessage'); @@ -573,7 +642,8 @@ describe('Universal Creative APS source', () => { }); expect(controller.isConnected).toBe(true); - expect(controller.style.display).toBe('none'); + expect(controller.style.getPropertyValue('display')).toBe('none'); + expect(controller.style.getPropertyPriority('display')).toBe('important'); expect(rendererFrame.style.display).toBe(''); expect(resultPost).toHaveBeenCalledWith( { @@ -584,9 +654,112 @@ describe('Universal Creative APS source', () => { }, '*' ); + + cancelPendingApsRender(container); + + expect(rendererFrame.isConnected).toBe(false); + expect(controller.isConnected).toBe(true); + expect(controller.style.getPropertyValue('display')).toBe('inline-block'); + expect(controller.style.getPropertyPriority('display')).toBe('important'); + document.body.innerHTML = ''; + }); + + it('removes the forced display declaration when the controller had none', () => { + document.body.innerHTML = + '
'; + const container = document.getElementById('fictional-puc-no-display')!; + const controller = container.querySelector('.puc-controller')!; + const requester = document.createElement('iframe'); + document.body.appendChild(requester); + const mountId = registerApsUniversalCreativeMount(container, descriptor())!; + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/mount-request', + mountId, + nonce: 'ZYXWVUTSRQPONMLKJIHGFE', + }, + source: requester.contentWindow, + }) + ); + + const rendererFrame = container.querySelector( + 'iframe[data-ts-aps-renderer="true"]' + )!; + const { channel, sent } = advanceRendererToData(rendererFrame); + sendRendererMessage(channel, { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + }); + expect(controller.style.getPropertyValue('display')).toBe('none'); + expect(controller.style.getPropertyPriority('display')).toBe('important'); + + cancelPendingApsRender(container); + + expect(controller.style.getPropertyValue('display')).toBe(''); + expect(controller.style.getPropertyPriority('display')).toBe(''); + expect(controller.style.cssText).toBe(''); document.body.innerHTML = ''; }); + it('keeps a committed mount when other slots exhaust pending mount capacity', () => { + document.body.innerHTML = + '
'; + const container = document.getElementById('fictional-puc-capacity')!; + const controller = container.querySelector('.puc-controller')!; + controller.style.setProperty('display', 'inline-block', 'important'); + const requester = document.createElement('iframe'); + document.body.appendChild(requester); + const mountId = registerApsUniversalCreativeMount(container, descriptor())!; + const fillers: HTMLElement[] = []; + + try { + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/mount-request', + mountId, + nonce: 'ZYXWVUTSRQPONMLKJIHGFE', + }, + source: requester.contentWindow, + }) + ); + const rendererFrame = container.querySelector( + 'iframe[data-ts-aps-renderer="true"]' + )!; + const { channel, sent } = advanceRendererToData(rendererFrame); + sendRendererMessage(channel, { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + }); + + for (let index = 0; index < 256; index += 1) { + const filler = document.createElement('div'); + document.body.appendChild(filler); + fillers.push(filler); + expect(registerApsUniversalCreativeMount(filler, descriptor())).toMatch( + /^[A-Za-z0-9_-]{22}$/ + ); + } + + expect(registerApsUniversalCreativeMount(container, descriptor())).toBeUndefined(); + expect(container.querySelector('iframe[data-ts-aps-renderer="true"]')).toBe(rendererFrame); + expect(rendererFrame.isConnected).toBe(true); + expect(controller.isConnected).toBe(true); + expect(controller.style.getPropertyValue('display')).toBe('none'); + expect(controller.style.getPropertyPriority('display')).toBe('important'); + } finally { + for (const filler of fillers) { + cancelPendingApsRender(filler); + filler.remove(); + } + cancelPendingApsRender(container); + requester.remove(); + document.body.innerHTML = ''; + } + }); + it('revokes an older mount capability when the same container is registered again', () => { document.body.innerHTML = '
'; const container = document.getElementById('fictional-refresh-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 6d73094cf..bf5ed40b2 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 @@ -2927,6 +2927,80 @@ describe('installTsRenderBridge', () => { return bridgeListener!; } + it('tears down APS state only for the exact GPT slot when a refresh is requested', async () => { + const cancelPendingApsRender = vi.fn(); + vi.doMock('../../../src/integrations/aps/render', async () => { + const actual = await vi.importActual( + '../../../src/integrations/aps/render' + ); + return { ...actual, cancelPendingApsRender }; + }); + + const matchingSlot = document.createElement('div'); + matchingSlot.id = 'div-header'; + const matchingContainer = document.createElement('div'); + matchingContainer.id = 'div-header-container'; + const otherSlot = document.createElement('div'); + otherSlot.id = 'div-other'; + document.body.append(matchingSlot, matchingContainer, otherSlot); + + let slotRequested: ((event: { slot: { getSlotElementId(): string } }) => void) | undefined; + const pubads = { + addEventListener: vi.fn( + (event: string, listener: (event: { slot: { getSlotElementId(): string } }) => void) => { + if (event === 'slotRequested') slotRequested = listener; + } + ), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + pubads: vi.fn().mockReturnValue(pubads), + }; + + try { + await import('../../../src/integrations/gpt/index'); + expect(slotRequested).toBeDefined(); + + slotRequested!({ slot: { getSlotElementId: () => 'div-header' } }); + + expect(cancelPendingApsRender).toHaveBeenCalledTimes(2); + expect(cancelPendingApsRender).toHaveBeenCalledWith(matchingSlot); + expect(cancelPendingApsRender).toHaveBeenCalledWith(matchingContainer); + expect(cancelPendingApsRender).not.toHaveBeenCalledWith(otherSlot); + } finally { + vi.doUnmock('../../../src/integrations/aps/render'); + matchingSlot.remove(); + matchingContainer.remove(); + otherSlot.remove(); + delete (window as TestWindow).googletag; + } + }); + + it('installs refresh teardown when the GPT shim activates after the bundle loads', async () => { + delete (window as TestWindow).googletag; + const { installGptShim } = await import('../../../src/integrations/gpt/index'); + + let slotRequested: ((event: { slot: { getSlotElementId(): string } }) => void) | undefined; + const pubads = { + addEventListener: vi.fn( + (event: string, listener: (event: { slot: { getSlotElementId(): string } }) => void) => { + if (event === 'slotRequested') slotRequested = listener; + } + ), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + pubads: vi.fn().mockReturnValue(pubads), + }; + + try { + expect(installGptShim()).toBe(true); + expect(slotRequested).toBeDefined(); + } finally { + delete (window as TestWindow).googletag; + } + }); + it('records an inline creative request and response with the same opaque attempt ID', async () => { const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(41); const recordTrustedServerCreativeResponse = vi.fn(); diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 3ab6e904b..cc6dfc01e 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -160,7 +160,7 @@ A network-loaded HTTPS creative does not inherit its ancestor's CSP and can crea Trusted Server generates independent 128-bit nonces for the container and renderer. Once the inner renderer is ready, the container transfers a dedicated `MessagePort` between trusted top-page TSJS and the inner renderer. The complete descriptor travels only over that port; the inner renderer revalidates it, rejects the publisher origin, and requires the creative origin to equal the origin bound in the container CSP. The container URL contains only that exact origin, static renderer source, and nonces—never the response envelope, bid ID, price, or creative URL path. -The inner document initializes the account-keyed APS queue and loads only the fixed runner at `https://client.aps.amazon-adsystem.com/prebid-creative.js`. A `renderer-ready` acknowledgement still means that Amazon's runner loaded, not that the final creative painted. Existing slot content remains until that acknowledgement. The query-free `GET /integrations/aps/renderer` response remains available with its original stricter CSP and legacy message shape for cached clients and rollback. +The inner document initializes the account-keyed APS queue and loads only the fixed runner at `https://client.aps.amazon-adsystem.com/prebid-creative.js`. A `renderer-ready` acknowledgement still means that Amazon's runner loaded, not that the final creative painted. Existing slot content remains until that acknowledgement. The query-free `GET /integrations/aps/renderer` response remains available with its original stricter CSP and legacy message shape for cached clients and rollback. If bootstrap readiness is delayed, TSJS posts that exact two-field legacy descriptor after a brief compatibility wait while continuing to accept a later modern bootstrap acknowledgement. ### Direct `/auction` @@ -172,6 +172,8 @@ For initial navigation and page-bids, Trusted Server publishes the same descript For client-side `trustedServer` adapter auctions, Prebid generates its own `hb_adid`. Trusted Server binds that generated ID to the validated APS descriptor in a bounded, expiring browser registry before GAM refresh. The bridge verifies that the requesting Universal Creative iframe belongs to the same ad unit, consumes both the renderer and mount capabilities once, and passes the APS bid ID separately to the Amazon runner. +After a Universal Creative APS mount commits, TSJS keeps the controller connected but hidden and records its prior inline display value. GPT's next `slotRequested` event removes only the committed APS frame, restores the controller's exact display value, and revokes unconsumed mount work for that slot before the refreshed creative lifecycle begins. + These paths do not fetch PBS Cache, fire generic APS win/billing beacons, or call `apstag.setDisplayBids()` for the Trusted Server winner. Publisher-owned native APS objects are otherwise left untouched. ## Publisher CSP diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 714de510b..d3b11d584 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -88,7 +88,7 @@ trap cleanup EXIT # --- Run tests for each framework --- for framework in nextjs wordpress; do echo "==> Running Playwright tests for $framework..." - TEST_FRAMEWORK="$framework" npx playwright test "$@" + TEST_FRAMEWORK="$framework" npx playwright test --project=chromium "$@" done echo "==> All browser tests passed." From 0953b5b63bbf1beaa2e74e48368a973f14ed9426 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 18 Aug 2026 14:31:00 -0500 Subject: [PATCH 3/4] Allow time for cross-browser Playwright setup --- .github/workflows/integration-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index da40ba7da..3bcef19e4 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -156,7 +156,7 @@ jobs: name: browser integration tests needs: prepare-artifacts runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 From 4db0d14a20b45ea443e80c3f8baccdfb313c7316 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 18 Aug 2026 14:46:19 -0500 Subject: [PATCH 4/4] Keep APS cross-browser checks local --- .github/workflows/integration-tests.yml | 31 +++---------------- .../browser/playwright.config.ts | 26 ++++++++++------ scripts/integration-tests-browser.sh | 2 +- 3 files changed, 22 insertions(+), 37 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3bcef19e4..4973afe44 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -156,7 +156,7 @@ jobs: name: browser integration tests needs: prepare-artifacts runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -198,7 +198,7 @@ jobs: working-directory: crates/trusted-server-integration-tests/browser run: | npm ci - npx playwright install --with-deps chromium firefox webkit + npx playwright install --with-deps chromium - name: Run browser tests (Next.js) working-directory: crates/trusted-server-integration-tests/browser @@ -208,7 +208,7 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml TEST_FRAMEWORK: nextjs PLAYWRIGHT_HTML_REPORT: playwright-report-nextjs - run: npx playwright test --project=chromium + run: npx playwright test - name: Upload Playwright report (Next.js) uses: actions/upload-artifact@v4 @@ -218,29 +218,6 @@ jobs: path: crates/trusted-server-integration-tests/browser/playwright-report-nextjs/ retention-days: 7 - - name: Run APS browser tests (Next.js, Firefox and WebKit) - working-directory: crates/trusted-server-integration-tests/browser - env: - WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} - INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml - TEST_FRAMEWORK: nextjs - PLAYWRIGHT_HTML_REPORT: playwright-report-aps-cross-browser - PLAYWRIGHT_OUTPUT_DIR: test-results-aps-cross-browser - run: >- - npx playwright test tests/shared/aps-renderer.spec.ts - --project=firefox --project=webkit - - - name: Upload APS cross-browser Playwright artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-aps-cross-browser - path: | - crates/trusted-server-integration-tests/browser/playwright-report-aps-cross-browser/ - crates/trusted-server-integration-tests/browser/test-results-aps-cross-browser/ - retention-days: 7 - - name: Run browser tests (WordPress) if: always() working-directory: crates/trusted-server-integration-tests/browser @@ -250,7 +227,7 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml TEST_FRAMEWORK: wordpress PLAYWRIGHT_HTML_REPORT: playwright-report-wordpress - run: npx playwright test --project=chromium + run: npx playwright test - name: Upload Playwright report (WordPress) uses: actions/upload-artifact@v4 diff --git a/crates/trusted-server-integration-tests/browser/playwright.config.ts b/crates/trusted-server-integration-tests/browser/playwright.config.ts index f52e43364..4afcf68d1 100644 --- a/crates/trusted-server-integration-tests/browser/playwright.config.ts +++ b/crates/trusted-server-integration-tests/browser/playwright.config.ts @@ -1,5 +1,20 @@ import { defineConfig } from "@playwright/test"; +// Firefox and WebKit are opt-in for targeted local validation. +const additionalBrowserProjects = + process.env.PLAYWRIGHT_CROSS_BROWSER === "1" + ? [ + { + name: "firefox", + use: { browserName: "firefox" as const }, + }, + { + name: "webkit", + use: { browserName: "webkit" as const }, + }, + ] + : []; + export default defineConfig({ testDir: "./tests", globalSetup: "./global-setup.ts", @@ -18,15 +33,8 @@ export default defineConfig({ name: "chromium", use: { browserName: "chromium" }, }, - { - name: "firefox", - use: { browserName: "firefox" }, - }, - { - name: "webkit", - use: { browserName: "webkit" }, - }, + ...additionalBrowserProjects, ], reporter: [["list"], ["html", { open: "never" }]], - outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR ?? "./test-results", + outputDir: "./test-results", }); diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index d3b11d584..714de510b 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -88,7 +88,7 @@ trap cleanup EXIT # --- Run tests for each framework --- for framework in nextjs wordpress; do echo "==> Running Playwright tests for $framework..." - TEST_FRAMEWORK="$framework" npx playwright test --project=chromium "$@" + TEST_FRAMEWORK="$framework" npx playwright test "$@" done echo "==> All browser tests passed."