diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4fed9278d..5dff15a17 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -23,8 +23,8 @@ use crate::auction::types::{ }; use crate::error::TrustedServerError; use crate::integrations::{ - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, - UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded, + IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, + IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded, ensure_integration_backend_with_timeout, predict_integration_backend_name, }; use crate::openrtb::{ @@ -50,6 +50,7 @@ const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-po const APS_RENDERER_DOCUMENT: &str = r#" + "#; +/// Rendering owner for selected APS bids. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApsRenderingMode { + /// Render through Trusted Server's opaque static renderer route. + #[default] + TrustedServer, + /// Render through the injected APS runner in a publisher-origin friendly frame. + PublisherNative, +} + /// Configuration for the APS `OpenRTB` integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[validate(schema(function = "validate_inventory_identity_override"))] @@ -141,6 +153,9 @@ pub struct ApsConfig { /// Whether APS script creatives are eligible before winner selection. #[serde(default)] pub allow_script_creatives: bool, + /// Rendering owner for selected APS bids. + #[serde(default)] + pub rendering_mode: ApsRenderingMode, /// APS-authorized inventory domain used instead of the deployment hostname. #[serde(default, skip_serializing_if = "Option::is_none")] #[validate(custom(function = "validate_inventory_domain"))] @@ -314,6 +329,7 @@ impl Default for ApsConfig { timeout_ms: default_timeout_ms(), debug: false, allow_script_creatives: false, + rendering_mode: ApsRenderingMode::TrustedServer, inventory_domain: None, inventory_page_origin: None, } @@ -1184,7 +1200,9 @@ impl AuctionProvider for ApsAuctionProvider { } #[derive(Debug)] -struct ApsRendererIntegration; +struct ApsRendererIntegration { + rendering_mode: ApsRenderingMode, +} #[async_trait(?Send)] impl IntegrationProxy for ApsRendererIntegration { @@ -1193,7 +1211,10 @@ impl IntegrationProxy for ApsRendererIntegration { } fn routes(&self) -> Vec { - vec![IntegrationEndpoint::get(APS_RENDERER_ROUTE)] + (self.rendering_mode == ApsRenderingMode::TrustedServer) + .then(|| IntegrationEndpoint::get(APS_RENDERER_ROUTE)) + .into_iter() + .collect() } async fn handle( @@ -1225,6 +1246,23 @@ impl IntegrationProxy for ApsRendererIntegration { } } +impl IntegrationHeadInjector for ApsRendererIntegration { + fn integration_id(&self) -> &'static str { + APS_INTEGRATION_ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + Vec::new() + } + + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + (self.rendering_mode == ApsRenderingMode::PublisherNative) + .then_some(("data-ts-aps-rendering-mode", "publisher_native")) + .into_iter() + .collect() + } +} + /// Register the APS static renderer endpoint when APS is enabled. /// /// # Errors @@ -1233,16 +1271,21 @@ impl IntegrationProxy for ApsRendererIntegration { pub fn register( settings: &Settings, ) -> Result, Report> { - let Some(_config) = settings.integration_config::(APS_INTEGRATION_ID)? else { + let Some(config) = settings.integration_config::(APS_INTEGRATION_ID)? else { return Ok(None); }; - let integration = Arc::new(ApsRendererIntegration); - Ok(Some( - IntegrationRegistration::builder(APS_INTEGRATION_ID) - .with_proxy(integration) - .without_js() - .build(), - )) + let integration = Arc::new(ApsRendererIntegration { + rendering_mode: config.rendering_mode, + }); + let registration = IntegrationRegistration::builder(APS_INTEGRATION_ID) + .without_js() + .with_head_injector(integration.clone()); + let registration = if config.rendering_mode == ApsRenderingMode::TrustedServer { + registration.with_proxy(integration) + } else { + registration + }; + Ok(Some(registration.build())) } /// Register the APS auction provider when enabled. @@ -1262,6 +1305,11 @@ pub fn register_providers( "APS debug mode is ON — raw request and response data, including creative markup, will be included in client-visible /auction responses" ); } + if config.rendering_mode == ApsRenderingMode::PublisherNative && config.allow_script_creatives { + log::warn!( + "APS publisher-native rendering with script creatives is ON; selected bidder scripts execute with publisher-origin privileges" + ); + } Ok(vec![Arc::new(ApsAuctionProvider::new(config))]) } @@ -1273,6 +1321,7 @@ mod tests { UserInfo, }; use crate::consent::ConsentContext; + use crate::integrations::IntegrationDocumentState; use crate::openrtb::{Eid, Uid}; use crate::platform::GeoInfo; use crate::platform::test_support::{ @@ -1289,6 +1338,7 @@ mod tests { timeout_ms: 800, debug: false, allow_script_creatives: false, + rendering_mode: ApsRenderingMode::TrustedServer, inventory_domain: None, inventory_page_origin: None, } @@ -1405,6 +1455,7 @@ mod tests { assert!(!canonical.debug); assert!(debug.debug); assert!(!canonical.allow_script_creatives); + assert_eq!(canonical.rendering_mode, ApsRenderingMode::TrustedServer); assert!(canonical.endpoint.ends_with("/e/pb/bid")); } @@ -1466,6 +1517,14 @@ mod tests { })) .is_err() ); + assert!( + serde_json::from_value::(json!({ + "account_id": "example-account", + "rendering_mode": "unsupported" + })) + .is_err(), + "should reject an unknown APS rendering mode" + ); for endpoint in [ "http://aps.example/e/pb/bid", "https://", @@ -2319,7 +2378,9 @@ mod tests { #[test] fn registers_and_serves_only_static_renderer_route() { - let integration = ApsRendererIntegration; + let integration = ApsRendererIntegration { + rendering_mode: ApsRenderingMode::TrustedServer, + }; let routes = integration.routes(); assert_eq!(routes.len(), 1, "should register one route"); assert_eq!(routes[0].method, Method::GET); @@ -2374,9 +2435,103 @@ mod tests { assert_eq!(registration.integration_id, APS_INTEGRATION_ID); assert_eq!(registration.proxies.len(), 1); + assert_eq!(registration.head_injectors.len(), 1); + let document_state = IntegrationDocumentState::default(); + let context = IntegrationHtmlContext { + request_host: "publisher.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + assert!( + registration.head_injectors[0] + .head_inserts(&context) + .is_empty(), + "should not inject a native-mode head marker by default" + ); + assert!( + registration.head_injectors[0] + .tsjs_script_tag_attributes() + .is_empty(), + "should not authorize native rendering by default" + ); assert!(registration.js_disabled); } + #[test] + fn publisher_native_config_registers_runner_mode_without_renderer_route() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + APS_INTEGRATION_ID, + &json!({ + "enabled": true, + "account_id": "example-account", + "rendering_mode": "publisher_native" + }), + ) + .expect("should insert native APS config"); + + let registration = register(&settings) + .expect("should register APS") + .expect("should return enabled registration"); + assert!( + registration.proxies.is_empty(), + "should not register the static renderer" + ); + assert_eq!(registration.head_injectors.len(), 1); + + let integration = ApsRendererIntegration { + rendering_mode: ApsRenderingMode::PublisherNative, + }; + assert!( + integration.routes().is_empty(), + "should expose no renderer route" + ); + let document_state = IntegrationDocumentState::default(); + let context = IntegrationHtmlContext { + request_host: "publisher.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + assert!( + integration.head_inserts(&context).is_empty(), + "should not inject a forgeable native-mode marker" + ); + assert_eq!( + integration.tsjs_script_tag_attributes(), + vec![("data-ts-aps-rendering-mode", "publisher_native")], + "should authorize native mode on the publisher bundle tag" + ); + } + + #[test] + fn publisher_native_script_creatives_remain_available_for_controlled_validation() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + APS_INTEGRATION_ID, + &json!({ + "enabled": true, + "account_id": "example-account", + "allow_script_creatives": true, + "rendering_mode": "publisher_native" + }), + ) + .expect("should insert native APS script config"); + + let providers = register_providers(&settings).expect("should register APS provider"); + + assert_eq!( + providers.len(), + 1, + "should retain the controlled experiment" + ); + } + #[test] fn config_without_enabled_does_not_register_provider_or_renderer() { let mut settings = create_test_settings(); @@ -2429,6 +2584,10 @@ mod tests { assert!(APS_RENDERER_DOCUMENT.contains("message.nonce!==expected")); assert!(APS_RENDERER_DOCUMENT.contains("prebid/creative/render")); assert!(APS_RENDERER_DOCUMENT.contains("window._aps instanceof Map")); + assert!( + APS_RENDERER_DOCUMENT + .contains("html,body{margin:0;padding:0}body>iframe{display:block}") + ); assert!(APS_RENDERER_DOCUMENT.contains("store:new Map([['listeners',new Map()]])")); assert!(APS_RENDERER_DOCUMENT.contains("account.queue.push(new CustomEvent")); assert!( 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 6c0301e2c..fce505d42 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 @@ -4,6 +4,7 @@ import { expect, test, type Page } from "@playwright/test"; import { runtimeUrl } from "../../helpers/state.js"; const RUNNER_URL = "https://client.aps.amazon-adsystem.com/prebid-creative.js"; +const PUBLISHER_CORE_URL = "https://tsjs.example/trusted-server-core.js"; const IFRAME_CREATIVE_URL = "https://creative.example/iframe"; const SCRIPT_CREATIVE_URL = "https://creative.example/script.js"; const SANDBOX = @@ -155,6 +156,9 @@ const FAKE_RUNNER = `(function(){ if (bid.ext.tagtype === 'iframe') { var frame = document.createElement('iframe'); frame.setAttribute('sandbox', 'allow-scripts allow-same-origin'); + frame.width = String(bid.w); + frame.height = String(bid.h); + frame.style.border = '0'; frame.src = bid.ext.creativeurl; document.body.appendChild(frame); } else { @@ -192,7 +196,7 @@ const SCRIPT_CREATIVE = `(function(){ }, '*'); })();`; -test.describe("APS opaque renderer", () => { +test.describe("APS rendering", () => { test("renders a trustedServer adapter bid using Prebid's generated GAM ad ID", async ({ page, }) => { @@ -956,4 +960,167 @@ parent.postMessage(JSON.stringify({ 1, ); }); + + test("renders publisher-native mode through the injected friendly-frame runner", async ({ + page, + }) => { + const publisherOrigin = "https://publisher.example"; + const auctionUrl = `${publisherOrigin}/auction`; + const testUrl = `${publisherOrigin}/aps-publisher-native-test`; + const renderer = descriptor("iframe"); + const coreBundle = readFileSync(clientAuctionBundlePaths().core, "utf8"); + let runnerRequests = 0; + let runnerReferrer: string | undefined; + let creativeReferrer: string | undefined; + + await page.route(PUBLISHER_CORE_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/javascript", + body: coreBundle, + }); + }); + await page.route(RUNNER_URL, async (route) => { + runnerRequests += 1; + runnerReferrer = route.request().headers()["referer"]; + await route.fulfill({ + status: 200, + contentType: "application/javascript", + body: FAKE_RUNNER, + }); + }); + await page.route(IFRAME_CREATIVE_URL, async (route) => { + creativeReferrer = route.request().headers()["referer"]; + await route.fulfill({ + status: 200, + contentType: "text/html", + body: IFRAME_CREATIVE, + }); + }); + await page.route(auctionUrl, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "fictional-native-auction", + seatbid: [ + { + seat: "aps", + bid: [ + { + id: renderer.bidId, + impid: "publisher-native-slot", + price: 1.23, + w: renderer.width, + h: renderer.height, + ext: { trusted_server: { renderer } }, + }, + ], + }, + ], + ext: {}, + }), + }); + }); + await page.route(testUrl, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/html", + headers: { + "Content-Security-Policy": + "default-src 'none'; script-src https://tsjs.example https://client.aps.amazon-adsystem.com https://creative.example; connect-src 'self'; frame-src https://creative.example", + }, + body: ` +
existing publisher content
`, + }); + }); + + await page.goto(testUrl); + await page.evaluate(async (scriptUrl) => { + await new Promise((resolveScript, rejectScript) => { + const script = document.createElement("script"); + script.setAttribute( + "data-ts-aps-rendering-mode", + "publisher_native", + ); + script.src = scriptUrl; + script.addEventListener("load", () => resolveScript(), { + once: true, + }); + script.addEventListener( + "error", + () => rejectScript(new Error("TSJS core failed to load")), + { once: true }, + ); + document.head.appendChild(script); + }); + }, PUBLISHER_CORE_URL); + await page.evaluate(() => { + const tsjs = ( + window as unknown as { + tsjs: { + addAdUnits(units: Array>): void; + requestAds(): void; + }; + } + ).tsjs; + tsjs.addAdUnits([ + { + code: "publisher-native-slot", + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [], + }, + ]); + tsjs.requestAds(); + }); + + await expect.poll(() => runnerRequests).toBe(1); + const frame = page.locator("#publisher-native-slot > iframe"); + await expect(frame).toHaveCount(1); + await expect(frame).toBeVisible(); + expect(await frame.getAttribute("sandbox")).toBeNull(); + await expect( + frame + .contentFrame() + .locator(`iframe[src="${IFRAME_CREATIVE_URL}"]`), + ).toHaveCount(1); + await expect( + page.locator("#publisher-native-slot .existing"), + ).toHaveCount(0); + expect(runnerReferrer).toBeUndefined(); + expect(creativeReferrer).toBeUndefined(); + expect( + await frame.evaluate((element: HTMLIFrameElement) => { + const document = element.contentDocument!; + const creative = document.body.querySelector("iframe")!; + return { + bodyMargin: getComputedStyle(document.body).margin, + bodyPadding: getComputedStyle(document.body).padding, + creativeDisplay: getComputedStyle(creative).display, + clientWidth: document.documentElement.clientWidth, + clientHeight: document.documentElement.clientHeight, + scrollWidth: document.documentElement.scrollWidth, + scrollHeight: document.documentElement.scrollHeight, + }; + }), + ).toEqual({ + bodyMargin: "0px", + bodyPadding: "0px", + creativeDisplay: "block", + clientWidth: 300, + clientHeight: 250, + scrollWidth: 300, + scrollHeight: 250, + }); + expect( + await page + .locator("#publisher-native-slot") + .evaluate( + (slot) => + slot.querySelectorAll( + 'iframe[src*="/integrations/aps/renderer"]', + ).length, + ), + ).toBe(0); + }); }); diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index df3fee6a6..c05070a9d 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -1,5 +1,5 @@ // Request orchestration for tsjs: unified auction endpoint with iframe-based creative rendering. -import { renderApsCreative } from '../integrations/aps/render'; +import { dispatchApsRendering, renderApsCreative } from '../integrations/aps/render'; import { buildAdRequest, sendAuction } from './auction'; import { collectContext } from './context'; @@ -52,7 +52,13 @@ export function requestAds( for (const bid of bids) { if (!bid.impid) continue; if (bid.renderer) { - renderApsCreative({ slotId: bid.impid, renderer: bid.renderer }); + void Promise.resolve( + dispatchApsRendering({ + slotId: bid.impid, + renderer: bid.renderer, + trustedServer: (renderer) => renderApsCreative({ slotId: bid.impid, renderer }), + }) + ); continue; } if (!bid.adm) { 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 85f17adf9..adec0b036 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,7 +1,12 @@ import { log } from '../../core/log'; +import { findSlot } from '../../core/render'; import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; export const APS_RENDERER_PATH = '/integrations/aps/renderer'; +export const APS_RENDERING_MODE_ATTRIBUTE_NAME = 'data-ts-aps-rendering-mode'; +export const APS_PREBID_CREATIVE_RUNNER_URL = + 'https://client.aps.amazon-adsystem.com/prebid-creative.js'; +export const APS_NATIVE_RENDERER_TIMEOUT_MS = 10_000; export const APS_RENDERER_SANDBOX = 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 4; @@ -38,6 +43,92 @@ type ValidatedRendererCacheEntry = { renderer: ApsRendererV1; }; const validatedRendererCache = new WeakMap(); +const nativeDispatches = new Map(); +const publisherNativeRendering = + typeof document !== 'undefined' && + document.currentScript?.getAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME) === 'publisher_native'; + +function releaseNativeDispatch(slotId: string, dispatch: symbol): boolean { + if (nativeDispatches.get(slotId) !== dispatch) return false; + nativeDispatches.delete(slotId); + return true; +} + +function sourceBelongsToElement( + source: MessageEventSource | null | undefined, + element: HTMLElement +): boolean { + return source + ? Array.from(element.querySelectorAll('iframe')).some( + (iframe) => iframe.contentWindow === source + ) + : false; +} + +function sourceMatchedCandidates( + candidates: HTMLElement[], + source?: MessageEventSource | null +): HTMLElement[] { + if (!source) return candidates; + const sourceMatches = candidates.filter((element) => sourceBelongsToElement(source, element)); + return sourceMatches.length > 0 ? sourceMatches : candidates; +} + +function dynamicSlotCandidates( + divIdPrefix: string, + source?: MessageEventSource | null +): HTMLElement[] { + const candidates = Array.from(document.querySelectorAll('[id]')).filter( + (element) => element.id.startsWith(divIdPrefix) && !element.id.endsWith('-container') + ); + return sourceMatchedCandidates(candidates, source); +} + +function uniqueSlotCandidate(candidates: HTMLElement[]): HTMLElement | null { + return candidates.length === 1 ? candidates[0]! : null; +} + +function findApsContainer(slotId: string, source?: MessageEventSource | null): HTMLElement | null { + try { + const mapping = window.tsjs?.divToSlotId ?? {}; + const mappedCandidates = sourceMatchedCandidates( + Object.entries(mapping) + .filter(([, mappedSlotId]) => mappedSlotId === slotId) + .map(([divId]) => findSlot(divId)) + .filter((element): element is HTMLElement => element !== null), + source + ); + const mapped = uniqueSlotCandidate(mappedCandidates); + if (mapped) return mapped; + + if (slotId.endsWith('-container')) { + const inner = findSlot(slotId.slice(0, -'-container'.length)); + if (inner) return inner; + } + + const direct = findSlot(slotId); + if (direct && !direct.id.endsWith('-container')) return direct; + + const configuredDivId = window.tsjs?.adSlots?.find((slot) => slot.id === slotId)?.div_id; + if (configuredDivId) { + const configured = findSlot(configuredDivId); + if (configured) return configured; + + const dynamic = uniqueSlotCandidate(dynamicSlotCandidates(configuredDivId, source)); + if (dynamic) return dynamic; + } + + const dynamic = uniqueSlotCandidate(dynamicSlotCandidates(slotId, source)); + return dynamic ?? direct; + } catch { + return null; + } +} + +function cancelPendingApsRendering(slotId: string, source?: MessageEventSource | null): void { + const container = findApsContainer(slotId, source); + if (container) pendingFrameCancels.get(container)?.(); +} function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -277,6 +368,204 @@ export function consumeApsPrebidRenderer(adId: string, expected: ApsPrebidRender return true; } +export interface DispatchApsRenderingOptions { + slotId: string; + renderer: unknown; + source?: MessageEventSource | null; + /** Existing Trusted Server owner, invoked only in the default mode. */ + trustedServer: (renderer: ApsRendererV1) => boolean; +} + +/** + * Dispatch a validated APS descriptor to exactly one configured rendering owner. + * + * Native mode loads APS's fixed Prebid creative runner in a publisher-origin friendly + * frame. Superseded attempts are cancelled and never fall back to the opaque renderer. + */ +export function dispatchApsRendering({ + slotId, + renderer: input, + source, + trustedServer, +}: DispatchApsRenderingOptions): boolean | Promise { + // Every native attempt supersedes a pending frame for this slot, including an + // invalid replacement. Default mode preserves a valid in-flight frame until a + // validated replacement reaches renderApsCreative. + if (publisherNativeRendering) cancelPendingApsRendering(slotId, source); + const dispatch = Symbol(slotId); + nativeDispatches.set(slotId, dispatch); + + const renderer = validateApsRenderer(input); + if (!renderer) { + releaseNativeDispatch(slotId, dispatch); + log.warn('APS renderer: rejected descriptor'); + return false; + } + if (!publisherNativeRendering) { + try { + return trustedServer(renderer); + } finally { + releaseNativeDispatch(slotId, dispatch); + } + } + + let rendering: Promise; + try { + rendering = renderApsPublisherNative({ slotId, renderer, source }); + } catch { + releaseNativeDispatch(slotId, dispatch); + log.warn('APS native renderer: failed to start publisher-origin frame'); + return Promise.resolve(false); + } + + return rendering.then((accepted) => { + if (!releaseNativeDispatch(slotId, dispatch)) { + if (accepted) log.warn('APS native renderer: ignored stale completion'); + return false; + } + return accepted; + }); +} + +interface RenderApsPublisherNativeOptions { + slotId: string; + renderer: unknown; + source?: MessageEventSource | null; +} + +function prepareApsRunnerDocument( + frameWindow: Window & typeof globalThis, + frameDocument: Document +): void { + for (const element of [frameDocument.documentElement, frameDocument.body]) { + element.style.margin = '0px'; + element.style.padding = '0px'; + } + + const normalizeFrame = (node: Node): void => { + if ( + node instanceof frameWindow.HTMLIFrameElement && + node.parentElement === frameDocument.body + ) { + node.style.display = 'block'; + } + }; + Array.from(frameDocument.body.children).forEach(normalizeFrame); + new frameWindow.MutationObserver((records) => { + for (const record of records) record.addedNodes.forEach(normalizeFrame); + }).observe(frameDocument.body, { childList: true }); +} + +/** Render the exact selected response through APS's fixed runner in a friendly iframe. */ +function renderApsPublisherNative({ + slotId, + renderer: input, + source, +}: RenderApsPublisherNativeOptions): Promise { + const renderer = validateApsRenderer(input); + const container = findApsContainer(slotId, source); + if (!renderer || !container) { + log.warn( + renderer ? 'APS native renderer: slot not found' : 'APS renderer: rejected descriptor' + ); + return Promise.resolve(false); + } + + // Keep an already committed creative visible until the replacement runner loads. + pendingFrameCancels.get(container)?.(); + const iframe = document.createElement('iframe'); + iframe.title = 'Ad content'; + iframe.width = String(renderer.width); + iframe.height = String(renderer.height); + iframe.style.border = '0'; + iframe.style.display = 'none'; + activeFrames.set(container, iframe); + + return new Promise((resolve) => { + let settled = false; + let runner: HTMLScriptElement | undefined; + + const cleanup = (): void => { + window.clearTimeout(timeoutId); + runner?.removeEventListener('load', commit); + runner?.removeEventListener('error', fail); + }; + const finish = (accepted: boolean, warning?: string): void => { + if (settled) return; + settled = true; + cleanup(); + if (pendingFrameCancels.get(container) === cancel) pendingFrameCancels.delete(container); + + if (!accepted || activeFrames.get(container) !== iframe || !iframe.isConnected) { + if (activeFrames.get(container) === iframe) activeFrames.delete(container); + iframe.remove(); + if (warning) log.warn(warning); + resolve(false); + return; + } + + for (const child of Array.from(container.children)) { + if (child !== iframe) child.remove(); + } + iframe.style.display = ''; + resolve(true); + }; + const cancel = (): void => finish(false); + function fail(): void { + finish(false, 'APS native renderer: creative runner failed'); + } + function commit(): void { + finish(true); + } + + const timeoutId = window.setTimeout( + () => finish(false, 'APS native renderer: creative runner timed out'), + APS_NATIVE_RENDERER_TIMEOUT_MS + ); + pendingFrameCancels.set(container, cancel); + container.appendChild(iframe); + + try { + const frameWindow = iframe.contentWindow as + | (Window & + typeof globalThis & { + _aps: Map> }>; + }) + | null; + const frameDocument = iframe.contentDocument; + if (!frameWindow || !frameDocument) { + fail(); + return; + } + + frameDocument.open(); + frameDocument.write( + '' + + '' + ); + frameDocument.close(); + prepareApsRunnerDocument(frameWindow, frameDocument); + frameWindow._aps = new Map(); + frameWindow._aps.set(renderer.accountId, { + queue: [ + new frameWindow.CustomEvent('prebid/creative/render', { + detail: { aaxResponse: renderer.aaxResponse, seatBidId: renderer.bidId }, + }), + ], + store: new Map([['listeners', new Map()]]), + }); + + runner = frameDocument.createElement('script'); + runner.src = APS_PREBID_CREATIVE_RUNNER_URL; + runner.addEventListener('load', commit, { once: true }); + runner.addEventListener('error', fail, { once: true }); + frameDocument.head.appendChild(runner); + } catch { + fail(); + } + }); +} + function createNonce(): string | undefined { if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') return undefined; 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 69fd01b6e..89b480c6f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -11,6 +11,7 @@ import { APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, apsRendererUrl, + dispatchApsRendering, consumeApsPrebidRenderer, getApsPrebidRenderer, validateApsRenderer, @@ -1699,29 +1700,50 @@ export function installTsRenderBridge(): void { e.stopImmediatePropagation(); if (!messageSourceBelongsToAdUnit(e.source, prebidRendererEntry.adUnitCode)) return; const renderer = validateApsRenderer(prebidRendererEntry.renderer); - const rendererUrl = apsRendererUrl(); - if (!renderer || !rendererUrl) return; - if (!hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return; + if (!renderer || !hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return; if (!consumeApsPrebidRenderer(adId, prebidRendererEntry)) return; recordConsumedPrebidApsId(consumedPrebidApsIds, adId, prebidRendererEntry.expiresAt); - port.postMessage( - JSON.stringify({ - message: 'Prebid Response', - adId, - renderer: APS_UNIVERSAL_CREATIVE_RENDERER, - rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, - rendererUrl, - apsRenderer: renderer, - width: renderer.width, - height: renderer.height, - }) - ); - - try { - prebidRendererEntry.markUsed(); - } catch (err) { - log.warn(`[tsjs-gpt] APS Prebid markUsed callback threw for '${adId}'`, err); + const markUsed = (): void => { + try { + prebidRendererEntry.markUsed(); + } catch (err) { + log.warn(`[tsjs-gpt] APS Prebid markUsed callback threw for '${adId}'`, err); + } + }; + const dispatched = dispatchApsRendering({ + slotId: prebidRendererEntry.adUnitCode, + renderer, + source: e.source, + trustedServer: (validatedRenderer) => { + const rendererUrl = apsRendererUrl(); + if (!rendererUrl) return false; + try { + port.postMessage( + JSON.stringify({ + message: 'Prebid Response', + adId, + renderer: APS_UNIVERSAL_CREATIVE_RENDERER, + rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + rendererUrl, + apsRenderer: validatedRenderer, + width: validatedRenderer.width, + height: validatedRenderer.height, + }) + ); + return true; + } catch (err) { + log.warn(`[tsjs-gpt] APS Prebid response post failed for '${adId}'`, err); + return false; + } + }, + }); + if (typeof dispatched === 'boolean') { + if (dispatched) markUsed(); + } else { + void dispatched.then((accepted) => { + if (accepted) markUsed(); + }); } return; } @@ -1750,19 +1772,35 @@ export function installTsRenderBridge(): void { e.stopImmediatePropagation(); if (consumedServerApsBySlot.get(slotId) === adId) return; const renderer = validateApsRenderer(matchedBid.renderer); - const rendererUrl = apsRendererUrl(); - if (!renderer || !rendererUrl) return; + if (!renderer) return; consumedServerApsBySlot.set(slotId, adId); - port.postMessage( - JSON.stringify({ - message: 'Prebid Response', - adId, - renderer: APS_UNIVERSAL_CREATIVE_RENDERER, - rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, - rendererUrl, - apsRenderer: renderer, - width: renderer.width, - height: renderer.height, + void Promise.resolve( + dispatchApsRendering({ + slotId, + renderer, + source: e.source, + trustedServer: (validatedRenderer) => { + const rendererUrl = apsRendererUrl(); + if (!rendererUrl) return false; + try { + port.postMessage( + JSON.stringify({ + message: 'Prebid Response', + adId, + renderer: APS_UNIVERSAL_CREATIVE_RENDERER, + rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + rendererUrl, + apsRenderer: validatedRenderer, + width: validatedRenderer.width, + height: validatedRenderer.height, + }) + ); + return true; + } catch (err) { + log.warn(`[tsjs-gpt] APS server response post failed for '${slotId}'`, err); + return false; + } + }, }) ); return; 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..e88ed520c 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AdUnit } from '../../src/core/types'; +import { + APS_PREBID_CREATIVE_RUNNER_URL, + APS_RENDERING_MODE_ATTRIBUTE_NAME, +} from '../../src/integrations/aps/render'; import envelope from '../fixtures/aps-renderer-v1.json'; async function flushRequestAds(): Promise { @@ -137,6 +141,73 @@ describe('request.requestAds', () => { expect(document.querySelector('#slot1 span')).toBeNull(); }); + it('contract test: renders a direct APS bid through the injected native runner', async () => { + const apsBid = envelope.seatbid[0].bid[0]; + const renderer = { + type: 'aps' as const, + version: 1 as const, + accountId: 'example-account-id', + bidId: apsBid.id, + tagType: apsBid.ext.tagtype as 'iframe', + creativeUrl: apsBid.ext.creativeurl, + aaxResponse: btoa(JSON.stringify(envelope)), + width: apsBid.w, + height: apsBid.h, + }; + const publisherScript = document.createElement('script'); + publisherScript.setAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME, 'publisher_native'); + const currentScriptSpy = vi + .spyOn(document, 'currentScript', 'get') + .mockReturnValue(publisherScript); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + seatbid: [ + { + seat: 'aps', + bid: [{ impid: 'slot1', ext: { trusted_server: { renderer } } }], + }, + ], + }), + }); + + try { + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + currentScriptSpy.mockRestore(); + document.body.innerHTML = '
existing
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } }); + + requestAds(); + await flushRequestAds(); + const frame = document.querySelector('#slot1 iframe')!; + const runner = frame.contentDocument?.querySelector('script'); + expect(runner).not.toBeNull(); + const frameWindow = frame.contentWindow as unknown as { + _aps: Map>> }>; + }; + const queued = frameWindow._aps.get(renderer.accountId)?.queue[0]; + + expect(frame.getAttribute('sandbox')).toBeNull(); + expect(runner!.src).toBe(APS_PREBID_CREATIVE_RUNNER_URL); + expect(queued?.type).toBe('prebid/creative/render'); + expect(queued?.detail).toEqual({ + aaxResponse: renderer.aaxResponse, + seatBidId: renderer.bidId, + }); + expect(document.querySelector('#slot1 span')).not.toBeNull(); + + runner!.dispatchEvent(new Event('load')); + await Promise.resolve(); + expect(document.querySelector('#slot1 span')).toBeNull(); + expect(frame.style.display).toBe(''); + } finally { + currentScriptSpy.mockRestore(); + } + }); + it('does not mutate the slot for an invalid APS descriptor', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, 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..d9a92742b 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,11 +4,15 @@ import envelope from '../../fixtures/aps-renderer-v1.json'; import type { ApsRendererV1 } from '../../../src/core/types'; import { log } from '../../../src/core/log'; import { + APS_NATIVE_RENDERER_TIMEOUT_MS, + APS_PREBID_CREATIVE_RUNNER_URL, APS_RENDERER_PATH, APS_RENDERER_SANDBOX, + APS_RENDERING_MODE_ATTRIBUTE_NAME, APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, apsRendererUrl, + dispatchApsRendering as dispatchDefaultApsRendering, getApsPrebidRenderer, parseApsRendererDescriptor, registerApsPrebidRenderer, @@ -16,6 +20,20 @@ import { validateApsRenderer, } from '../../../src/integrations/aps/render'; +function nativeRunnerState(frame: HTMLIFrameElement): { + runner: HTMLScriptElement; + event: CustomEvent<{ aaxResponse: string; seatBidId: string }>; +} { + const runner = frame.contentDocument?.querySelector('script'); + const frameWindow = frame.contentWindow as unknown as { + _aps: Map> }>; + }; + const account = frameWindow._aps.get('example-account-id'); + expect(runner).not.toBeNull(); + expect(account?.queue).toHaveLength(1); + return { runner: runner!, event: account!.queue[0] }; +} + function encodeBytes(bytes: Uint8Array): string { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); @@ -261,6 +279,257 @@ describe('Prebid APS renderer registry', () => { }); }); +describe('APS rendering-mode authorization', () => { + it('ignores mode markers and duplicate script tags injected after module initialization', () => { + document.body.innerHTML = '
'; + document.head.insertAdjacentHTML( + 'beforeend', + '' + + '' + ); + const trustedServer = vi.fn(() => true); + + expect( + dispatchDefaultApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer, + }) + ).toBe(true); + expect(trustedServer).toHaveBeenCalledOnce(); + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + + document.head + .querySelectorAll( + 'meta[name="trusted-server-aps-rendering-mode"], script[data-ts-aps-rendering-mode]' + ) + .forEach((element) => element.remove()); + document.body.innerHTML = ''; + }); +}); + +describe('publisher-native APS runner contract tests', () => { + let dispatchApsRendering: typeof dispatchDefaultApsRendering; + + beforeEach(async () => { + vi.resetModules(); + document.body.innerHTML = '
existing
'; + const publisherScript = document.createElement('script'); + publisherScript.setAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME, 'publisher_native'); + const currentScriptSpy = vi + .spyOn(document, 'currentScript', 'get') + .mockReturnValue(publisherScript); + ({ dispatchApsRendering } = await import('../../../src/integrations/aps/render')); + currentScriptSpy.mockRestore(); + }); + + afterEach(() => { + delete window.tsjs; + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('queues the exact selected response for the fixed APS runner and commits on load', async () => { + const trustedServer = vi.fn(() => true); + const accepted = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer, + }); + const slot = document.getElementById('fictional-slot')!; + const frame = slot.querySelector('iframe')!; + const { runner, event } = nativeRunnerState(frame); + + expect(frame.getAttribute('sandbox')).toBeNull(); + expect(frame.style.display).toBe('none'); + expect(runner.src).toBe(APS_PREBID_CREATIVE_RUNNER_URL); + expect(event.type).toBe('prebid/creative/render'); + expect(event.detail).toEqual({ + aaxResponse: descriptor().aaxResponse, + seatBidId: descriptor().bidId, + }); + expect(slot.querySelector('span')).not.toBeNull(); + expect(trustedServer).not.toHaveBeenCalled(); + + const runnerDocument = frame.contentDocument!; + expect(runnerDocument.querySelector('meta[name="referrer"]')?.getAttribute('content')).toBe( + 'no-referrer' + ); + expect(runnerDocument.documentElement.style.margin).toBe('0px'); + expect(runnerDocument.documentElement.style.padding).toBe('0px'); + expect(runnerDocument.body.style.margin).toBe('0px'); + expect(runnerDocument.body.style.padding).toBe('0px'); + const creativeFrame = runnerDocument.createElement('iframe'); + runnerDocument.body.appendChild(creativeFrame); + await vi.waitFor(() => expect(creativeFrame.style.display).toBe('block')); + + runner.dispatchEvent(new Event('load')); + await expect(accepted).resolves.toBe(true); + expect(slot.querySelector('span')).toBeNull(); + expect(frame.style.display).toBe(''); + }); + + it('fails closed when the runner fails without clearing publisher content', async () => { + const trustedServer = vi.fn(() => true); + const accepted = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer, + }); + const frame = document.querySelector('#fictional-slot iframe')!; + const { runner } = nativeRunnerState(frame); + + runner.dispatchEvent(new Event('error')); + + await expect(accepted).resolves.toBe(false); + expect(trustedServer).not.toHaveBeenCalled(); + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot span')).not.toBeNull(); + }); + + it('cancels a pending runner when a newer dispatch replaces it', async () => { + const first = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + const firstFrame = document.querySelector('#fictional-slot iframe')!; + + const second = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + const secondFrame = document.querySelector('#fictional-slot iframe')!; + + expect(firstFrame.isConnected).toBe(false); + expect(secondFrame).not.toBe(firstFrame); + await expect(first).resolves.toBe(false); + nativeRunnerState(secondFrame).runner.dispatchEvent(new Event('load')); + await expect(second).resolves.toBe(true); + }); + + it('lets an invalid replacement cancel an older pending runner', async () => { + const first = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + const second = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor({ aaxResponse: 'invalid' }), + trustedServer: () => true, + }); + + expect(second).toBe(false); + await expect(first).resolves.toBe(false); + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot span')).not.toBeNull(); + }); + + it('resolves a logical GPT slot through the injected div mapping', async () => { + document.body.innerHTML = '
existing
'; + window.tsjs = { divToSlotId: { 'div-header': 'homepage_header' } } as typeof window.tsjs; + + const accepted = dispatchApsRendering({ + slotId: 'homepage_header', + renderer: descriptor(), + trustedServer: () => true, + }); + const frame = document.querySelector('#div-header iframe')!; + nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); + + await expect(accepted).resolves.toBe(true); + expect(document.querySelector('#div-header span')).toBeNull(); + }); + + it('renders inside the inner slot when Prebid uses its container ID', async () => { + document.body.innerHTML = + '
'; + const source = document.querySelector('#div-header > iframe')!.contentWindow; + + const accepted = dispatchApsRendering({ + slotId: 'div-header-container', + renderer: descriptor(), + source, + trustedServer: () => true, + }); + const frame = Array.from( + document.querySelectorAll('#div-header > iframe') + ).find((candidate) => candidate.title === 'Ad content')!; + nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); + + await expect(accepted).resolves.toBe(true); + expect(document.getElementById('div-header-container')).not.toBeNull(); + expect(document.getElementById('div-header')).not.toBeNull(); + expect(document.querySelectorAll('#div-header > iframe')).toHaveLength(1); + }); + + it('uses the requesting frame to resolve a dynamic slot prefix', async () => { + document.body.innerHTML = + '
' + + '
'; + const source = document.querySelector( + '#div-header-second > iframe' + )!.contentWindow; + + const accepted = dispatchApsRendering({ + slotId: 'div-header-', + renderer: descriptor(), + source, + trustedServer: () => true, + }); + const frame = Array.from( + document.querySelectorAll('#div-header-second > iframe') + ).find((candidate) => candidate.title === 'Ad content')!; + nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); + + await expect(accepted).resolves.toBe(true); + expect(document.querySelector('#div-header-first > iframe')).not.toBeNull(); + expect(document.querySelectorAll('#div-header-second > iframe')).toHaveLength(1); + }); + + it('contains throwing publisher slot mappings without falling back', async () => { + const tsjs = {} as NonNullable; + Object.defineProperty(tsjs, 'divToSlotId', { + get: () => { + throw new Error('fictional mapping lookup failure'); + }, + }); + window.tsjs = tsjs; + const trustedServer = vi.fn(() => true); + + await expect( + dispatchApsRendering({ + slotId: 'logical-slot', + renderer: descriptor(), + trustedServer, + }) + ).resolves.toBe(false); + expect(trustedServer).not.toHaveBeenCalled(); + expect(document.querySelector('iframe')).toBeNull(); + }); + + it('times out an unacknowledged runner without clearing publisher content', async () => { + vi.useFakeTimers(); + try { + const result = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + await vi.advanceTimersByTimeAsync(APS_NATIVE_RENDERER_TIMEOUT_MS); + + await expect(result).resolves.toBe(false); + expect(vi.getTimerCount()).toBe(0); + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot span')).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('direct APS rendering', () => { beforeEach(() => { document.body.innerHTML = '
existing
'; @@ -271,6 +540,42 @@ describe('direct APS rendering', () => { document.body.innerHTML = ''; }); + it('keeps a valid default frame when an invalid replacement is rejected', () => { + const trustedServer = (renderer: ApsRendererV1): boolean => + renderApsCreative({ slotId: 'fictional-slot', renderer }); + expect( + dispatchDefaultApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer, + }) + ).toBe(true); + + const slot = document.getElementById('fictional-slot')!; + const iframe = slot.querySelector('iframe')!; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + const sent = postMessage.mock.calls[0][0] as { nonce: string }; + + expect( + dispatchDefaultApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor({ aaxResponse: 'invalid' }), + trustedServer, + }) + ).toBe(false); + expect(iframe.isConnected).toBe(true); + + window.dispatchEvent( + new MessageEvent('message', { + data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, + source: iframe.contentWindow, + }) + ); + expect(slot.querySelector('span')).toBeNull(); + expect(iframe.style.display).toBe(''); + }); + it('loads the static route with a fragment-bound 128-bit nonce and opaque sandbox', () => { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); 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 fb9cdecf0..b7186518b 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 @@ -7,6 +7,42 @@ import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vites import envelope from '../../fixtures/aps-renderer-v1.json'; import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; +import { + APS_PREBID_CREATIVE_RUNNER_URL, + APS_RENDERING_MODE_ATTRIBUTE_NAME, +} from '../../../src/integrations/aps/render'; + +let publisherNativeScript: HTMLScriptElement | undefined; + +function enablePublisherNativeMode(): { remove(): void } { + publisherNativeScript = document.createElement('script'); + publisherNativeScript.setAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME, 'publisher_native'); + return { + remove: () => { + publisherNativeScript = undefined; + }, + }; +} + +function nativeRunnerIn(divId: string): { + frame: HTMLIFrameElement; + runner: HTMLScriptElement; + event: CustomEvent<{ aaxResponse: string; seatBidId: string }>; +} { + const container = document.getElementById(divId)!; + const frame = Array.from(container.querySelectorAll('iframe')).find( + (candidate) => candidate.title === 'Ad content' + ); + expect(frame).not.toBeUndefined(); + const runner = frame!.contentDocument?.querySelector('script'); + const frameWindow = frame!.contentWindow as unknown as { + _aps: Map> }>; + }; + const event = Array.from(frameWindow._aps.values())[0]?.queue[0]; + expect(runner?.src).toBe(APS_PREBID_CREATIVE_RUNNER_URL); + expect(event).not.toBeUndefined(); + return { frame: frame!, runner: runner!, event }; +} function apsRenderer() { const bid = envelope.seatbid[0].bid[0]; @@ -2940,6 +2976,7 @@ describe('installTsRenderBridge', () => { beforeEach(() => { vi.resetModules(); + publisherNativeScript = undefined; // Remove ALL accumulated 'message' handlers from previous test module imports // to prevent stale bridge listeners from intercepting our test event. for (const handler of allMessageHandlers) { @@ -3000,6 +3037,9 @@ describe('installTsRenderBridge', () => { async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { let bridgeListener: ((e: MessageEvent) => unknown) | undefined; const origAdd = window.addEventListener.bind(window); + const currentScriptSpy = publisherNativeScript + ? vi.spyOn(document, 'currentScript', 'get').mockReturnValue(publisherNativeScript) + : undefined; const addSpy = vi .spyOn(window, 'addEventListener') .mockImplementation( @@ -3014,6 +3054,7 @@ describe('installTsRenderBridge', () => { ); await import('../../../src/integrations/gpt/index'); addSpy.mockRestore(); + currentScriptSpy?.mockRestore(); expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); return bridgeListener!; @@ -3289,6 +3330,78 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('contract test: renders a server APS owner with the injected runner and no Universal Creative response', async () => { + const renderer = apsRenderer(); + (window as TestWindow).tsjs.bids.homepage_header = { + hb_adid: renderer.bidId, + renderer, + }; + const marker = enablePublisherNativeMode(); + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const portMessages: string[] = []; + const request = Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), + ports: [{ postMessage: (message: string) => portMessages.push(message) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent; + + bridgeListener(request); + bridgeListener(request); + const native = nativeRunnerIn('div-header'); + expect(native.event.type).toBe('prebid/creative/render'); + expect(native.event.detail).toEqual({ + aaxResponse: renderer.aaxResponse, + seatBidId: renderer.bidId, + }); + native.runner.dispatchEvent(new Event('load')); + await Promise.resolve(); + await Promise.resolve(); + + expect(native.frame.style.display).toBe(''); + expect(portMessages).toEqual([]); + expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); + } finally { + marker.remove(); + } + }); + + it('contract test: fails a server APS runner without a Universal Creative response or fallback', async () => { + const renderer = apsRenderer(); + (window as TestWindow).tsjs.bids.homepage_header = { + hb_adid: renderer.bidId, + renderer, + }; + const marker = enablePublisherNativeMode(); + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const portMessages: string[] = []; + const request = Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), + ports: [{ postMessage: (message: string) => portMessages.push(message) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent; + + bridgeListener(request); + nativeRunnerIn('div-header').runner.dispatchEvent(new Event('error')); + await Promise.resolve(); + await Promise.resolve(); + bridgeListener(request); + + expect(portMessages).toEqual([]); + expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); + expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); + } finally { + marker.remove(); + } + }); + it('serves a registered Prebid APS renderer when its generated ad ID differs from the APS bid ID', async () => { const renderer = apsRenderer(); const prebidAdId = 'prebid-generated-ad-id'; @@ -3344,6 +3457,137 @@ describe('installTsRenderBridge', () => { foreignIframe.remove(); }); + it('contract test: fails a registered APS runner without a Universal Creative response or markUsed', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'native-prebid-decline-ad-id'; + const markUsed = vi.fn(); + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed, + }, + }; + const marker = enablePublisherNativeMode(); + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const portMessages: string[] = []; + const request = Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: (message: string) => portMessages.push(message) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent; + + bridgeListener(request); + nativeRunnerIn('div-header').runner.dispatchEvent(new Event('error')); + await Promise.resolve(); + await Promise.resolve(); + bridgeListener(request); + + expect(markUsed).not.toHaveBeenCalled(); + expect(portMessages).toEqual([]); + expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); + expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); + } finally { + marker.remove(); + } + }); + + it('contract test: consumes a registered APS capability and marks it used only after runner load', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'native-prebid-ad-id'; + const markUsed = vi.fn(); + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed, + }, + }; + const marker = enablePublisherNativeMode(); + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const portMessages: string[] = []; + const request = Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: (message: string) => portMessages.push(message) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent; + + bridgeListener(request); + expect(markUsed).not.toHaveBeenCalled(); + const native = nativeRunnerIn('div-header'); + native.runner.dispatchEvent(new Event('load')); + await Promise.resolve(); + await Promise.resolve(); + bridgeListener(request); + + expect(native.frame.style.display).toBe(''); + expect(markUsed).toHaveBeenCalledOnce(); + expect(portMessages).toEqual([]); + expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + } finally { + marker.remove(); + } + }); + + it('uses the requesting frame to resolve a registered APS dynamic slot prefix', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'native-dynamic-prebid-ad-id'; + const markUsed = vi.fn(); + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-native-', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed, + }, + }; + const marker = enablePublisherNativeMode(); + const firstSource = createTrustedSlotIframe('div-native-first'); + const source = createTrustedSlotIframe('div-native-second'); + + try { + const bridgeListener = await captureBridgeListener(); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: vi.fn() }], + source, + 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); + } finally { + marker.remove(); + document.getElementById('div-native-first')?.remove(); + document.getElementById('div-native-second')?.remove(); + } + }); + it('still serves the APS renderer when markUsed throws', async () => { const renderer = apsRenderer(); const prebidAdId = 'throwing-mark-used-ad-id'; diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 658f5d5bc..bde759c45 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -36,6 +36,8 @@ debug = false # inventory_domain = "publisher.example" # inventory_page_origin = "https://www.publisher.example" allow_script_creatives = false +# Default. Set publisher_native only for the controlled friendly-frame experiment below. +rendering_mode = "trusted_server" [auction] enabled = true @@ -49,6 +51,28 @@ timeout_ms = 2000 `allow_script_creatives` defaults to `false`. While disabled, APS script bids are rejected before per-impression reduction, floors, mediation, and winner selection. Enable it only for a controlled cohort after the browser-security checks in [Rollout](#rollout) pass. +`rendering_mode` is a strict enum: `trusted_server` (the default) retains the opaque static renderer route, and `publisher_native` disables that route and adds `data-ts-aps-rendering-mode="publisher_native"` to the server-generated TSJS bundle tag. TSJS captures this server-owned attribute when the bundle executes, so markup added later cannot change the mode. The attribute works under a publisher CSP that blocks inline scripts. Unknown values fail configuration deserialization. + +### Publisher-native runner experiment + +`publisher_native` is an opt-in browser experiment, **not** general APS compatibility proof. No public `apstag` API was found that accepts an externally selected OpenRTB `aaxResponse`. In controlled browser testing, `apstag.renderImp(document, bidId)` did not render the Trusted Server bid because that bid was absent from the SDK's browser-auction state. Trusted Server therefore does not call `apstag`, `fetchBids`, or `setDisplayBids`, mutate the publisher's APS SDK, or start a second auction. Instead, this mode reuses the same `prebid/creative/render` runner contract already used by `trusted_server` mode, but inside a publisher-origin frame; that observed vendor contract still requires APS account-team validation. + +No publisher JavaScript change is required. After validating and freezing the exact selected descriptor, Trusted Server JS: + +1. resolves the direct-auction slot or its injected GAM div mapping; +2. creates a hidden, publisher-origin friendly iframe sized to the winner; +3. initializes only that fresh frame's account-scoped `_aps` event queue; +4. queues `prebid/creative/render` with the selected `aaxResponse` and bid ID; and +5. loads the fixed `https://client.aps.amazon-adsystem.com/prebid-creative.js` runner. + +The existing publisher content remains visible until the runner script loads. A runner error, a blocked script, a missing slot, a superseding dispatch, or a load taking longer than 10 seconds removes the pending frame and visibly declines the bid. It never falls back to `/integrations/aps/renderer` or sends a Universal Creative renderer response. Trusted Server treats runner load as successful handoff; the runner owns subsequent creative completion and resource loading. + +Unlike `trusted_server` mode, this friendly frame deliberately has no opaque-origin sandbox. Its initial document inherits the publisher CSP, so the publisher policy controls whether the APS runner and required creative resources can load. Trusted Server sets the frame document's referrer policy to `no-referrer`, matching the static renderer's existing protection. The fixed APS runner and its creative otherwise execute with publisher-origin privileges, so `publisher_native` has a larger security surface, especially when `allow_script_creatives = true`. Use only a controlled cohort. + +For a client-side Prebid APS capability, Trusted Server consumes the one-shot capability before starting the runner and calls `markWinningBidAsUsed` only after the runner loads. For server/GPT ownership, it similarly claims the slot/ad ID first. This prevents native and Trusted Server rendering from both owning the same response. + +Disable or coordinate existing publisher-native APS demand for every `publisher_native` cohort. Otherwise the publisher's normal APS auction and this server-selected bid can duplicate demand. Validate the exact account, inventory, CSP, iframe/script creative behavior, impression reporting, and click-through behavior with the APS account team before any production rollout. + Set `inventory_domain` and `inventory_page_origin` together only when the public deployment hostname differs from the inventory identity authorized by APS. The domain becomes `site.domain`. The HTTPS page origin replaces the current page's scheme and host while preserving its path; query and fragment data are removed before forwarding. The origin must be the inventory domain or one of its subdomains and cannot include credentials, a port, path, query, or fragment. These values come only from operator configuration; Trusted Server never accepts APS inventory identity from the client auction payload. APS uses ordinary auction slot IDs and banner formats. Legacy creative-opportunity APS `slot_id` configuration is accepted for compatibility but ignored, and `bidders.aps.slotID` is not required. Remove both during migration. @@ -150,7 +174,7 @@ 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`. +In `trusted_server` mode, 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`. The outer iframe uses these sandbox permissions: @@ -167,11 +191,13 @@ It deliberately omits `allow-same-origin`, so APS and bidder execution remains b ### 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. +In `trusted_server` mode, the TSJS auction client validates the typed renderer descriptor, creates the opaque renderer iframe, and sends the minimized envelope after the frame loads. In `publisher_native` mode it creates the injected friendly iframe and queues the response for the fixed APS Prebid creative runner. 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` 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. + +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. 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. @@ -209,17 +235,20 @@ This release is a direct protocol cutover: There is no legacy runtime switch. Roll back by disabling `[integrations.aps]`, restoring native APS for the cohort, or deploying the prior binary. +Changing `rendering_mode` does not update pages that are already loaded or stored in an HTML cache. A cached `trusted_server` page can continue requesting `/integrations/aps/renderer` after a native-mode deployment removes that route. A cached `publisher_native` page continues using its captured native mode after rollback. Coordinate the mode change with HTML cache expiry or purge and reload active test sessions before judging the result. + ## Rollout Use fictional values in source-controlled configuration and fixtures. Supply controlled account details out of band. 1. Obtain APS account-team confirmation for edge-originated OpenRTB traffic. 2. Enable Trusted Server APS only for an isolated cohort and disable native APS demand there. -3. Keep `allow_script_creatives = false` and observe iframe bids through direct and GAM paths. +3. Keep the default `trusted_server` mode and `allow_script_creatives = false`; observe iframe bids through direct and GAM paths. 4. Confirm outbound privacy fields, aggregate diagnostics, decoded-price competition, line-item targeting, dimensions, click-throughs, and opaque-origin isolation. -5. Run the restrictive-CSP browser proof for script behavior. -6. Only then enable script creatives for the isolated cohort and validate them in a real browser. -7. Expand traffic only after APS confirmation and successful controlled validation. +5. In a still-smaller cohort, set `rendering_mode = "publisher_native"` and confirm the fixed runner request, friendly-frame dimensions, iframe creatives, impression reporting, and click-throughs without a request to `/integrations/aps/renderer`. +6. Purge or expire cached HTML and reload active test sessions when changing modes. Confirm the publisher CSP permits the runner but does not need to permit inline Trusted Server scripts. +7. Only after reviewing the friendly-frame security tradeoff, enable script creatives for the isolated native cohort and validate them in a real browser. +8. Expand traffic only after APS confirmation and successful controlled validation. ## Troubleshooting @@ -235,12 +264,12 @@ 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'`. +- In `trusted_server` mode, confirm `GET /integrations/aps/renderer` returns HTML with its CSP and `Referrer-Policy: no-referrer`, and that publisher CSP permits `frame-src 'self'`. +- In `publisher_native` mode, confirm the `#trustedserver-js` bundle tag carries `data-ts-aps-rendering-mode="publisher_native"`, the slot receives a hidden friendly iframe, and publisher CSP does not block `https://client.aps.amazon-adsystem.com/prebid-creative.js` or the selected creative's resources. The static renderer route is intentionally absent in this mode. Runner script load is the handoff signal, not proof that the creative painted. - 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. -- Keep script creatives disabled while diagnosing iframe rendering. +- Ensure no publisher APS auction is trying to handle the same cohort. +- Keep script creatives disabled while diagnosing either rendering mode. ## Verification diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 71f0f8f78..7c7e83635 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -184,6 +184,9 @@ debug = false # inventory_page_origin = "https://www.publisher.example" # Script creatives require separate security validation before opt-in. allow_script_creatives = false +# Default: Trusted Server's opaque static renderer route. Set publisher_native only +# for the controlled publisher-origin friendly-frame experiment. +rendering_mode = "trusted_server" [integrations.google_tag_manager] enabled = false