From 4b8e04e41d2b9b85ff9b12471ba0eb79c0f4f9fa Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 19 Aug 2026 09:00:18 -0500 Subject: [PATCH 1/5] Add APS publisher rendering experiment Provide an opt-in publisher hook for testing whether a Trusted Server-selected APS bid can use publisher-owned rendering without silently starting another auction or falling back to the custom renderer. Keep the existing opaque renderer as the default while real publisher and APS compatibility is validated.\n\nSee also: #999 --- .../src/integrations/aps.rs | 126 ++++++++- .../trusted-server-js/lib/src/core/request.ts | 10 +- .../trusted-server-js/lib/src/core/types.ts | 17 ++ .../lib/src/integrations/aps/render.ts | 143 +++++++++- .../lib/src/integrations/gpt/index.ts | 100 ++++--- .../lib/test/core/request.test.ts | 52 ++++ .../lib/test/integrations/aps/render.test.ts | 266 ++++++++++++++++++ .../lib/test/integrations/gpt/ad_init.test.ts | 168 +++++++++++ docs/guide/integrations/aps.md | 33 ++- trusted-server.example.toml | 3 + 10 files changed, 867 insertions(+), 51 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4fed9278d..fba758973 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::{ @@ -114,6 +114,17 @@ addEventListener('message',receive); "#; +/// 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, + /// Delegate rendering to the publisher's explicit browser hook. + PublisherNative, +} + /// Configuration for the APS `OpenRTB` integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[validate(schema(function = "validate_inventory_identity_override"))] @@ -141,6 +152,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 +328,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 +1199,9 @@ impl AuctionProvider for ApsAuctionProvider { } #[derive(Debug)] -struct ApsRendererIntegration; +struct ApsRendererIntegration { + rendering_mode: ApsRenderingMode, +} #[async_trait(?Send)] impl IntegrationProxy for ApsRendererIntegration { @@ -1193,7 +1210,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 +1245,22 @@ impl IntegrationProxy for ApsRendererIntegration { } } +impl IntegrationHeadInjector for ApsRendererIntegration { + fn integration_id(&self) -> &'static str { + APS_INTEGRATION_ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + (self.rendering_mode == ApsRenderingMode::PublisherNative) + .then(|| { + "" + .to_string() + }) + .into_iter() + .collect() + } +} + /// Register the APS static renderer endpoint when APS is enabled. /// /// # Errors @@ -1233,16 +1269,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. @@ -1273,6 +1314,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 +1331,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 +1448,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 +1510,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 +2371,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 +2428,55 @@ mod tests { assert_eq!(registration.integration_id, APS_INTEGRATION_ID); assert_eq!(registration.proxies.len(), 1); + assert_eq!(registration.head_injectors.len(), 1); assert!(registration.js_disabled); } + #[test] + fn publisher_native_config_registers_hook_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_eq!( + integration.head_inserts(&context), + vec![""], + "should inject only the native-mode marker" + ); + } + #[test] fn config_without_enabled_does_not_register_provider_or_renderer() { let mut settings = create_test_settings(); 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/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 0c68d43fe..192b82033 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -68,6 +68,21 @@ export interface ApsRendererV1 { export type AuctionBidRenderer = ApsRendererV1; +/** Explicit acknowledgement returned by the opt-in publisher-native APS hook. */ +export interface ApsNativeRendererResult { + accepted: boolean; + reason?: string; +} + +/** Publisher-owned rendering seam for a fully validated APS descriptor. */ +export interface ApsNativeRendererHook { + render(input: { + version: 1; + slotId: string; + renderer: ApsRendererV1; + }): ApsNativeRendererResult | Promise; +} + /** A client-side Prebid bid's generated ad ID bound to its APS render capability. */ export interface ApsPrebidRendererEntry { adUnitCode: string; @@ -388,6 +403,8 @@ export interface TsjsApi { * `hb_adid`. The Universal Creative bridge consumes each entry at most once. */ apsPrebidRenderers?: Record; + /** Opt-in publisher-owned renderer for exact, validated APS descriptors. */ + apsNativeRenderer?: ApsNativeRendererHook; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ adInit?: () => void; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ 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..f5faa60a1 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,14 @@ import { log } from '../../core/log'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import type { + ApsNativeRendererHook, + ApsPrebidRendererEntry, + ApsRendererV1, + TsjsApi, +} from '../../core/types'; export const APS_RENDERER_PATH = '/integrations/aps/renderer'; +export const APS_RENDERING_MODE_META_NAME = 'trusted-server-aps-rendering-mode'; +export const APS_NATIVE_RENDERER_ACK_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 +45,13 @@ type ValidatedRendererCacheEntry = { renderer: ApsRendererV1; }; const validatedRendererCache = new WeakMap(); +const nativeDispatches = new Map(); + +function releaseNativeDispatch(slotId: string, dispatch: symbol): boolean { + if (nativeDispatches.get(slotId) !== dispatch) return false; + nativeDispatches.delete(slotId); + return true; +} function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -277,6 +291,133 @@ export function consumeApsPrebidRenderer(adId: string, expected: ApsPrebidRender return true; } +/** Whether the server explicitly selected the opt-in publisher-native hook mode. */ +export function isPublisherNativeApsRendering(): boolean { + return ( + document.head.querySelector( + `meta[name="${APS_RENDERING_MODE_META_NAME}"][content="publisher_native"]` + ) !== null + ); +} + +export interface DispatchApsRenderingOptions { + slotId: string; + renderer: unknown; + /** 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. + * + * Publisher hooks own side-effect cancellation and render completion. Trusted Server + * ignores superseded hook acknowledgements and never falls back to its iframe. + */ +export function dispatchApsRendering({ + slotId, + renderer: input, + trustedServer, +}: DispatchApsRenderingOptions): boolean | Promise { + // Record every attempt before any early return so it supersedes an older + // pending native acknowledgement for the same slot. + 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 (!isPublisherNativeApsRendering()) { + try { + return trustedServer(renderer); + } finally { + releaseNativeDispatch(slotId, dispatch); + } + } + + let hook: ApsNativeRendererHook | undefined; + let render: ApsNativeRendererHook['render'] | undefined; + try { + hook = window.tsjs?.apsNativeRenderer; + render = hook?.render; + } catch { + releaseNativeDispatch(slotId, dispatch); + log.warn('APS native renderer: publisher hook lookup threw'); + return Promise.resolve(false); + } + if (!hook || typeof render !== 'function') { + releaseNativeDispatch(slotId, dispatch); + log.warn('APS native renderer: publisher hook is unavailable'); + return Promise.resolve(false); + } + + let response: unknown; + try { + response = Reflect.apply(render, hook, [{ version: 1, slotId, renderer }]); + } catch { + releaseNativeDispatch(slotId, dispatch); + log.warn('APS native renderer: publisher hook threw'); + return Promise.resolve(false); + } + + if (nativeDispatches.get(slotId) !== dispatch) { + log.warn('APS native renderer: ignored stale acknowledgement'); + return Promise.resolve(false); + } + + return new Promise((resolve) => { + let settled = false; + const settle = (accepted: boolean, warning?: string): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (!releaseNativeDispatch(slotId, dispatch)) { + log.warn('APS native renderer: ignored stale acknowledgement'); + resolve(false); + return; + } + if (warning) log.warn(warning); + resolve(accepted); + }; + const timeout = setTimeout(() => { + settle(false, 'APS native renderer: publisher hook acknowledgement timed out'); + }, APS_NATIVE_RENDERER_ACK_TIMEOUT_MS); + + Promise.resolve(response).then( + (value) => { + let accepted: boolean; + try { + if ( + !isRecord(value) || + (!hasExactKeys(value, ['accepted']) && !hasExactKeys(value, ['accepted', 'reason'])) || + typeof value.accepted !== 'boolean' || + (Object.prototype.hasOwnProperty.call(value, 'reason') && + typeof value.reason !== 'string') + ) { + settle(false, 'APS native renderer: publisher hook returned malformed acknowledgement'); + return; + } + accepted = value.accepted; + } catch { + settle(false, 'APS native renderer: publisher hook returned malformed acknowledgement'); + return; + } + + if (!accepted) { + settle(false, 'APS native renderer: publisher hook declined descriptor'); + return; + } + settle(true); + }, + () => { + settle(false, 'APS native renderer: publisher hook rejected'); + } + ); + }); +} + 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 f0df35974..d5fb0a59f 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, @@ -1676,29 +1677,49 @@ 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, + 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; } @@ -1727,19 +1748,34 @@ 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, + 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..1f8e032ba 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,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AdUnit } from '../../src/core/types'; +import { APS_RENDERING_MODE_META_NAME } from '../../src/integrations/aps/render'; import envelope from '../fixtures/aps-renderer-v1.json'; async function flushRequestAds(): Promise { @@ -137,6 +138,57 @@ describe('request.requestAds', () => { expect(document.querySelector('#slot1 span')).toBeNull(); }); + it('contract test: dispatches a direct APS bid to the native publisher hook without an iframe', 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 render = vi.fn().mockResolvedValue({ accepted: true }); + window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; + const marker = document.createElement('meta'); + marker.name = APS_RENDERING_MODE_META_NAME; + marker.content = 'publisher_native'; + document.head.appendChild(marker); + 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'); + document.body.innerHTML = '
existing
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } }); + + requestAds(); + await flushRequestAds(); + await Promise.resolve(); + + expect(render).toHaveBeenCalledWith({ version: 1, slotId: 'slot1', renderer }); + expect(document.querySelector('#slot1 iframe')).toBeNull(); + expect(document.querySelector('#slot1 span')).not.toBeNull(); + } finally { + marker.remove(); + } + }); + 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..627ae1268 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,14 @@ 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_ACK_TIMEOUT_MS, APS_RENDERER_PATH, APS_RENDERER_SANDBOX, + APS_RENDERING_MODE_META_NAME, APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, apsRendererUrl, + dispatchApsRendering, getApsPrebidRenderer, parseApsRendererDescriptor, registerApsPrebidRenderer, @@ -16,6 +19,19 @@ import { validateApsRenderer, } from '../../../src/integrations/aps/render'; +function enablePublisherNativeMode(): void { + const marker = document.createElement('meta'); + marker.name = APS_RENDERING_MODE_META_NAME; + marker.content = 'publisher_native'; + document.head.appendChild(marker); +} + +function disablePublisherNativeMode(): void { + document.head + .querySelectorAll(`meta[name="${APS_RENDERING_MODE_META_NAME}"]`) + .forEach((marker) => marker.remove()); +} + function encodeBytes(bytes: Uint8Array): string { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); @@ -261,6 +277,256 @@ describe('Prebid APS renderer registry', () => { }); }); +describe('publisher-native APS hook contract tests', () => { + beforeEach(() => { + document.body.innerHTML = '
existing
'; + enablePublisherNativeMode(); + }); + + afterEach(() => { + disablePublisherNativeMode(); + delete window.tsjs; + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('contract test: sends the exact frozen descriptor to an accepting publisher hook without an iframe', async () => { + const render = vi.fn().mockResolvedValue({ accepted: true }); + window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; + const unrelatedMarker = document.createElement('meta'); + unrelatedMarker.name = APS_RENDERING_MODE_META_NAME; + unrelatedMarker.content = 'trusted_server'; + document.head.appendChild(unrelatedMarker); + + const accepted = await dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => { + throw new Error('trusted renderer must not run'); + }, + }); + + expect(accepted).toBe(true); + expect(render).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledWith({ + version: 1, + slotId: 'fictional-slot', + renderer: descriptor(), + }); + const payload = render.mock.calls[0][0]; + expect(Object.keys(payload).sort()).toEqual(['renderer', 'slotId', 'version']); + expect(Object.isFrozen(payload.renderer)).toBe(true); + expect(document.querySelector('iframe')).toBeNull(); + }); + + it('contract test: declines missing, throwing, rejecting, and malformed hooks without fallback', async () => { + const trustedServer = vi.fn(() => true); + await expect( + dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) + ).resolves.toBe(false); + + window.tsjs = { + apsNativeRenderer: { + render: () => { + throw new Error('fictional'); + }, + }, + } as typeof window.tsjs; + await expect( + dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) + ).resolves.toBe(false); + + window.tsjs = { + apsNativeRenderer: { render: () => Promise.reject(new Error('fictional')) }, + } as typeof window.tsjs; + await expect( + dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) + ).resolves.toBe(false); + + window.tsjs = { + apsNativeRenderer: { render: () => ({ accepted: false }) }, + } as typeof window.tsjs; + await expect( + dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) + ).resolves.toBe(false); + + window.tsjs = { + apsNativeRenderer: { render: () => ({ accepted: 'yes' }) }, + } as typeof window.tsjs; + await expect( + dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) + ).resolves.toBe(false); + + expect(trustedServer).not.toHaveBeenCalled(); + expect(document.querySelector('iframe')).toBeNull(); + }); + + it('contract test: ignores a stale acknowledgement after a replacement dispatch', async () => { + let resolveFirst: ((value: { accepted: boolean }) => void) | undefined; + const render = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce({ accepted: true }); + window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; + + const first = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + const second = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + resolveFirst!({ accepted: true }); + + await expect(first).resolves.toBe(false); + await expect(second).resolves.toBe(true); + }); + + it('contract test: a missing hook supersedes an older pending dispatch', async () => { + let resolveFirst: ((value: { accepted: boolean }) => void) | undefined; + const render = vi.fn( + () => + new Promise<{ accepted: boolean }>((resolve) => { + resolveFirst = resolve; + }) + ); + window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; + + const first = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + delete window.tsjs!.apsNativeRenderer; + const second = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + resolveFirst!({ accepted: true }); + + await expect(second).resolves.toBe(false); + await expect(first).resolves.toBe(false); + }); + + it('contract test: a trusted-server dispatch supersedes an older native dispatch', async () => { + let resolveFirst: ((value: { accepted: boolean }) => void) | undefined; + window.tsjs = { + apsNativeRenderer: { + render: () => + new Promise<{ accepted: boolean }>((resolve) => { + resolveFirst = resolve; + }), + }, + } as typeof window.tsjs; + const first = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + + disablePublisherNativeMode(); + const trustedServer = vi.fn(() => true); + const second = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer, + }); + resolveFirst!({ accepted: true }); + + expect(second).toBe(true); + expect(trustedServer).toHaveBeenCalledOnce(); + await expect(first).resolves.toBe(false); + }); + + it('contract test: contains throwing hook and acknowledgement accessors', async () => { + const tsjs = {} as NonNullable; + Object.defineProperty(tsjs, 'apsNativeRenderer', { + get: () => { + throw new Error('fictional hook lookup failure'); + }, + }); + window.tsjs = tsjs; + + await expect( + dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }) + ).resolves.toBe(false); + + window.tsjs = { + apsNativeRenderer: { + render: () => + new Proxy( + { accepted: true }, + { + ownKeys: () => { + throw new Error('fictional acknowledgement inspection failure'); + }, + } + ), + }, + } as typeof window.tsjs; + await expect( + dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }) + ).resolves.toBe(false); + }); + + it('contract test: times out a hook and ignores its late acknowledgement', async () => { + vi.useFakeTimers(); + try { + let resolveHook: ((value: { accepted: boolean }) => void) | undefined; + window.tsjs = { + apsNativeRenderer: { + render: () => + new Promise<{ accepted: boolean }>((resolve) => { + resolveHook = resolve; + }), + }, + } as typeof window.tsjs; + + const result = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }); + await vi.advanceTimersByTimeAsync(APS_NATIVE_RENDERER_ACK_TIMEOUT_MS); + + await expect(result).resolves.toBe(false); + expect(vi.getTimerCount()).toBe(0); + resolveHook!({ accepted: true }); + await Promise.resolve(); + + window.tsjs = { + apsNativeRenderer: { render: () => ({ accepted: true }) }, + } as typeof window.tsjs; + await expect( + dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer: () => true, + }) + ).resolves.toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('direct APS rendering', () => { beforeEach(() => { document.body.innerHTML = '
existing
'; 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..7f23cd394 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,15 @@ 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_RENDERING_MODE_META_NAME } from '../../../src/integrations/aps/render'; + +function enablePublisherNativeMode(): HTMLMetaElement { + const marker = document.createElement('meta'); + marker.name = APS_RENDERING_MODE_META_NAME; + marker.content = 'publisher_native'; + document.head.appendChild(marker); + return marker; +} function apsRenderer() { const bid = envelope.seatbid[0].bid[0]; @@ -3197,6 +3206,79 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('contract test: delegates a server APS owner to the native hook without a Universal Creative response', async () => { + const renderer = apsRenderer(); + const render = vi.fn().mockResolvedValue({ accepted: true }); + (window as TestWindow).tsjs.bids.homepage_header = { + hb_adid: renderer.bidId, + renderer, + }; + (window as TestWindow).tsjs.apsNativeRenderer = { render }; + 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); + await Promise.resolve(); + await Promise.resolve(); + + expect(render).toHaveBeenCalledOnce(); + expect(render).toHaveBeenCalledWith({ + version: 1, + slotId: 'homepage_header', + renderer, + }); + expect(portMessages).toEqual([]); + expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); + } finally { + marker.remove(); + } + }); + + it('contract test: declines a server APS owner without a Universal Creative response or fallback', async () => { + const renderer = apsRenderer(); + const render = vi.fn().mockResolvedValue({ accepted: false }); + (window as TestWindow).tsjs.bids.homepage_header = { + hb_adid: renderer.bidId, + renderer, + }; + (window as TestWindow).tsjs.apsNativeRenderer = { render }; + 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); + await Promise.resolve(); + await Promise.resolve(); + bridgeListener(request); + + expect(render).toHaveBeenCalledOnce(); + expect(portMessages).toEqual([]); + 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'; @@ -3252,6 +3334,92 @@ describe('installTsRenderBridge', () => { foreignIframe.remove(); }); + it('contract test: declines a registered APS capability without a Universal Creative response or markUsed', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'native-prebid-decline-ad-id'; + const markUsed = vi.fn(); + const render = vi.fn().mockResolvedValue({ accepted: false }); + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed, + }, + }; + (window as TestWindow).tsjs.apsNativeRenderer = { render }; + 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); + await Promise.resolve(); + await Promise.resolve(); + bridgeListener(request); + + expect(render).toHaveBeenCalledOnce(); + expect(markUsed).not.toHaveBeenCalled(); + expect(portMessages).toEqual([]); + expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + 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 native acceptance', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'native-prebid-ad-id'; + const markUsed = vi.fn(); + const render = vi.fn().mockResolvedValue({ accepted: true }); + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed, + }, + }; + (window as TestWindow).tsjs.apsNativeRenderer = { render }; + 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(); + await Promise.resolve(); + await Promise.resolve(); + bridgeListener(request); + + expect(render).toHaveBeenCalledOnce(); + expect(markUsed).toHaveBeenCalledOnce(); + expect(portMessages).toEqual([]); + expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + } finally { + marker.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..9a5f4cb7e 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 with a publisher-installed hook (below). +rendering_mode = "trusted_server" [auction] enabled = true @@ -49,6 +51,31 @@ 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 emits the inert `` head marker selecting the publisher hook below. The marker works under a publisher CSP that blocks inline scripts. Unknown values fail configuration deserialization. + +### Publisher-native hook experiment + +`publisher_native` is an opt-in publisher integration seam, **not** APS compatibility proof. No public APS API was found that accepts an externally selected OpenRTB `aaxResponse` for native rendering. In particular, `apstag.setDisplayBids()` operates on APS's own `fetchBids()` state and is not an ingestion API for the exact Trusted Server-selected bid. Trusted Server does not call `apstag`, `fetchBids`, or `setDisplayBids`, mutate APS internals, or start a second auction. + +Before Trusted Server JS receives a selected descriptor, the publisher must install this versioned hook: + +```js +window.tsjs = window.tsjs || {} +window.tsjs.apsNativeRenderer = { + render({ version, slotId, renderer }) { + // version is exactly 1; renderer is frozen and fully validated. + // Render only this exact selected descriptor through publisher-owned logic. + return { accepted: true } + }, +} +``` + +The hook receives exactly `{ version: 1, slotId, renderer }`. It must return or resolve an object with `accepted: boolean`; `reason?: string` is allowed for fictional-safe diagnostics. Missing hooks, throws, rejected promises, malformed acknowledgements, `{ accepted: false }`, and acknowledgements that take longer than 10 seconds visibly decline the bid. They never fall back to the Trusted Server iframe or send a Universal Creative renderer response. A newer dispatch makes an older acknowledgement stale and ignored; late acknowledgements after the timeout are also ignored. Hook implementations own cancellation of any already-started side effect and actual render completion. + +For a client-side Prebid APS capability, Trusted Server consumes the one-shot capability before delegation and calls `markWinningBidAsUsed` only after `accepted: true`. For server/GPT ownership, it similarly claims the slot/ad ID before invoking the hook. This prevents native and Trusted Server rendering from both owning the same response, but an accepting hook is responsible for real rendering semantics. + +Disable or coordinate publisher-native APS demand for every `publisher_native` cohort. Otherwise native APS demand and this server-selected bid can duplicate demand. Validate a controlled real publisher/account setup 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 +177,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 +194,11 @@ 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 instead calls the explicit publisher hook and creates no Trusted Server iframe. 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`. In `publisher_native` mode the ownership-checked bridge calls the publisher hook without sending a Universal Creative renderer response; in `trusted_server` mode it uses the static dynamic renderer described below. 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 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. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..6e844e385 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -155,6 +155,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 +# when the publisher installs the documented tsjs.apsNativeRenderer hook. +rendering_mode = "trusted_server" [integrations.google_tag_manager] enabled = false From 6078a46c34c33bed5f727789dc2c8d45691ef647 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 19 Aug 2026 11:38:41 -0500 Subject: [PATCH 2/5] Inject APS publisher-native runner The test publisher cannot install a custom rendering hook. Reuse the existing APS Prebid creative runner contract in a publisher-origin friendly frame so the experiment remains self-contained while preserving the selected server bid and avoiding a second auction.\n\nDocument the larger security surface and retain the opaque renderer as the default.\n\nSee also: #999 --- .../src/integrations/aps.rs | 4 +- .../browser/tests/shared/aps-renderer.spec.ts | 111 +++++++- .../trusted-server-js/lib/src/core/types.ts | 17 -- .../lib/src/integrations/aps/render.ts | 211 +++++++++++----- .../lib/test/core/request.test.ts | 33 ++- .../lib/test/integrations/aps/render.test.ts | 239 +++++++----------- .../lib/test/integrations/gpt/ad_init.test.ts | 65 +++-- docs/guide/integrations/aps.md | 54 ++-- trusted-server.example.toml | 2 +- 9 files changed, 441 insertions(+), 295 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index fba758973..d6037ce5d 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -121,7 +121,7 @@ pub enum ApsRenderingMode { /// Render through Trusted Server's opaque static renderer route. #[default] TrustedServer, - /// Delegate rendering to the publisher's explicit browser hook. + /// Render through the injected APS runner in a publisher-origin friendly frame. PublisherNative, } @@ -2433,7 +2433,7 @@ mod tests { } #[test] - fn publisher_native_config_registers_hook_mode_without_renderer_route() { + fn publisher_native_config_registers_runner_mode_without_renderer_route() { let mut settings = create_test_settings(); settings .integrations 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..2e8e418d2 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 @@ -192,7 +192,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 +956,113 @@ 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"); + let runnerRequests = 0; + + await page.route(RUNNER_URL, async (route) => { + runnerRequests += 1; + await route.fulfill({ + status: 200, + contentType: "application/javascript", + body: FAKE_RUNNER, + }); + }); + await page.route(IFRAME_CREATIVE_URL, async (route) => { + 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 'unsafe-inline' 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.addScriptTag({ path: clientAuctionBundlePaths().core }); + 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( + 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/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 192b82033..0c68d43fe 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -68,21 +68,6 @@ export interface ApsRendererV1 { export type AuctionBidRenderer = ApsRendererV1; -/** Explicit acknowledgement returned by the opt-in publisher-native APS hook. */ -export interface ApsNativeRendererResult { - accepted: boolean; - reason?: string; -} - -/** Publisher-owned rendering seam for a fully validated APS descriptor. */ -export interface ApsNativeRendererHook { - render(input: { - version: 1; - slotId: string; - renderer: ApsRendererV1; - }): ApsNativeRendererResult | Promise; -} - /** A client-side Prebid bid's generated ad ID bound to its APS render capability. */ export interface ApsPrebidRendererEntry { adUnitCode: string; @@ -403,8 +388,6 @@ export interface TsjsApi { * `hb_adid`. The Universal Creative bridge consumes each entry at most once. */ apsPrebidRenderers?: Record; - /** Opt-in publisher-owned renderer for exact, validated APS descriptors. */ - apsNativeRenderer?: ApsNativeRendererHook; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ adInit?: () => void; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ 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 f5faa60a1..25bf82231 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,14 +1,12 @@ import { log } from '../../core/log'; -import type { - ApsNativeRendererHook, - ApsPrebidRendererEntry, - ApsRendererV1, - TsjsApi, -} from '../../core/types'; +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_META_NAME = 'trusted-server-aps-rendering-mode'; -export const APS_NATIVE_RENDERER_ACK_TIMEOUT_MS = 10_000; +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; @@ -53,6 +51,29 @@ function releaseNativeDispatch(slotId: string, dispatch: symbol): boolean { return true; } +function findApsContainer(slotId: string): HTMLElement | null { + const direct = findSlot(slotId); + if (direct) return direct; + + try { + for (const [divId, mappedSlotId] of Object.entries(window.tsjs?.divToSlotId ?? {})) { + if (mappedSlotId !== slotId) continue; + const mapped = findSlot(divId); + if (mapped) return mapped; + } + + const configuredDivId = window.tsjs?.adSlots?.find((slot) => slot.id === slotId)?.div_id; + return configuredDivId ? findSlot(configuredDivId) : null; + } catch { + return null; + } +} + +function cancelPendingApsRendering(slotId: string): void { + const container = findApsContainer(slotId); + if (container) pendingFrameCancels.get(container)?.(); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -291,7 +312,7 @@ export function consumeApsPrebidRenderer(adId: string, expected: ApsPrebidRender return true; } -/** Whether the server explicitly selected the opt-in publisher-native hook mode. */ +/** Whether the server explicitly selected the opt-in publisher-native runner mode. */ export function isPublisherNativeApsRendering(): boolean { return ( document.head.querySelector( @@ -310,16 +331,17 @@ export interface DispatchApsRenderingOptions { /** * Dispatch a validated APS descriptor to exactly one configured rendering owner. * - * Publisher hooks own side-effect cancellation and render completion. Trusted Server - * ignores superseded hook acknowledgements and never falls back to its iframe. + * 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, trustedServer, }: DispatchApsRenderingOptions): boolean | Promise { - // Record every attempt before any early return so it supersedes an older - // pending native acknowledgement for the same slot. + // Every attempt supersedes a pending frame for this slot, including an invalid + // replacement that fails before a new frame can be created. + cancelPendingApsRendering(slotId); const dispatch = Symbol(slotId); nativeDispatches.set(slotId, dispatch); @@ -337,84 +359,133 @@ export function dispatchApsRendering({ } } - let hook: ApsNativeRendererHook | undefined; - let render: ApsNativeRendererHook['render'] | undefined; + let rendering: Promise; try { - hook = window.tsjs?.apsNativeRenderer; - render = hook?.render; + rendering = renderApsPublisherNative({ slotId, renderer }); } catch { releaseNativeDispatch(slotId, dispatch); - log.warn('APS native renderer: publisher hook lookup threw'); - return Promise.resolve(false); - } - if (!hook || typeof render !== 'function') { - releaseNativeDispatch(slotId, dispatch); - log.warn('APS native renderer: publisher hook is unavailable'); + log.warn('APS native renderer: failed to start publisher-origin frame'); return Promise.resolve(false); } - let response: unknown; - try { - response = Reflect.apply(render, hook, [{ version: 1, slotId, renderer }]); - } catch { - releaseNativeDispatch(slotId, dispatch); - log.warn('APS native renderer: publisher hook threw'); - return Promise.resolve(false); - } + return rendering.then((accepted) => { + if (!releaseNativeDispatch(slotId, dispatch)) { + log.warn('APS native renderer: ignored stale completion'); + return false; + } + return accepted; + }); +} + +export interface RenderApsPublisherNativeOptions { + slotId: string; + renderer: unknown; +} - if (nativeDispatches.get(slotId) !== dispatch) { - log.warn('APS native renderer: ignored stale acknowledgement'); +/** Render the exact selected response through APS's fixed runner in a friendly iframe. */ +export function renderApsPublisherNative({ + slotId, + renderer: input, +}: RenderApsPublisherNativeOptions): Promise { + const renderer = validateApsRenderer(input); + const container = findApsContainer(slotId); + 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; - const settle = (accepted: boolean, warning?: string): void => { + 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; - clearTimeout(timeout); - if (!releaseNativeDispatch(slotId, dispatch)) { - log.warn('APS native renderer: ignored stale acknowledgement'); + 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; } - if (warning) log.warn(warning); - resolve(accepted); - }; - const timeout = setTimeout(() => { - settle(false, 'APS native renderer: publisher hook acknowledgement timed out'); - }, APS_NATIVE_RENDERER_ACK_TIMEOUT_MS); - - Promise.resolve(response).then( - (value) => { - let accepted: boolean; - try { - if ( - !isRecord(value) || - (!hasExactKeys(value, ['accepted']) && !hasExactKeys(value, ['accepted', 'reason'])) || - typeof value.accepted !== 'boolean' || - (Object.prototype.hasOwnProperty.call(value, 'reason') && - typeof value.reason !== 'string') - ) { - settle(false, 'APS native renderer: publisher hook returned malformed acknowledgement'); - return; - } - accepted = value.accepted; - } catch { - settle(false, 'APS native renderer: publisher hook returned malformed acknowledgement'); - return; - } - if (!accepted) { - settle(false, 'APS native renderer: publisher hook declined descriptor'); - return; - } - settle(true); - }, - () => { - settle(false, 'APS native renderer: publisher hook rejected'); + 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(); + 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(); + } }); } 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 1f8e032ba..bfc6c88f6 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AdUnit } from '../../src/core/types'; -import { APS_RENDERING_MODE_META_NAME } from '../../src/integrations/aps/render'; +import { + APS_PREBID_CREATIVE_RUNNER_URL, + APS_RENDERING_MODE_META_NAME, +} from '../../src/integrations/aps/render'; import envelope from '../fixtures/aps-renderer-v1.json'; async function flushRequestAds(): Promise { @@ -138,7 +141,7 @@ describe('request.requestAds', () => { expect(document.querySelector('#slot1 span')).toBeNull(); }); - it('contract test: dispatches a direct APS bid to the native publisher hook without an iframe', async () => { + 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, @@ -151,8 +154,6 @@ describe('request.requestAds', () => { width: apsBid.w, height: apsBid.h, }; - const render = vi.fn().mockResolvedValue({ accepted: true }); - window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; const marker = document.createElement('meta'); marker.name = APS_RENDERING_MODE_META_NAME; marker.content = 'publisher_native'; @@ -179,11 +180,27 @@ describe('request.requestAds', () => { requestAds(); await flushRequestAds(); - await Promise.resolve(); - - expect(render).toHaveBeenCalledWith({ version: 1, slotId: 'slot1', renderer }); - expect(document.querySelector('#slot1 iframe')).toBeNull(); + 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 { marker.remove(); } 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 627ae1268..dfe673ac5 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,7 +4,8 @@ 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_ACK_TIMEOUT_MS, + APS_NATIVE_RENDERER_TIMEOUT_MS, + APS_PREBID_CREATIVE_RUNNER_URL, APS_RENDERER_PATH, APS_RENDERER_SANDBOX, APS_RENDERING_MODE_META_NAME, @@ -32,6 +33,20 @@ function disablePublisherNativeMode(): void { .forEach((marker) => marker.remove()); } +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); @@ -277,7 +292,7 @@ describe('Prebid APS renderer registry', () => { }); }); -describe('publisher-native APS hook contract tests', () => { +describe('publisher-native APS runner contract tests', () => { beforeEach(() => { document.body.innerHTML = '
existing
'; enablePublisherNativeMode(); @@ -290,143 +305,98 @@ describe('publisher-native APS hook contract tests', () => { document.body.innerHTML = ''; }); - it('contract test: sends the exact frozen descriptor to an accepting publisher hook without an iframe', async () => { - const render = vi.fn().mockResolvedValue({ accepted: true }); - window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; + it('queues the exact selected response for the fixed APS runner and commits on load', async () => { + const trustedServer = vi.fn(() => true); const unrelatedMarker = document.createElement('meta'); unrelatedMarker.name = APS_RENDERING_MODE_META_NAME; unrelatedMarker.content = 'trusted_server'; document.head.appendChild(unrelatedMarker); - const accepted = await dispatchApsRendering({ + const accepted = dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), - trustedServer: () => { - throw new Error('trusted renderer must not run'); - }, + trustedServer, }); - - expect(accepted).toBe(true); - expect(render).toHaveBeenCalledTimes(1); - expect(render).toHaveBeenCalledWith({ - version: 1, - slotId: 'fictional-slot', - renderer: descriptor(), + 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, }); - const payload = render.mock.calls[0][0]; - expect(Object.keys(payload).sort()).toEqual(['renderer', 'slotId', 'version']); - expect(Object.isFrozen(payload.renderer)).toBe(true); - expect(document.querySelector('iframe')).toBeNull(); + expect(slot.querySelector('span')).not.toBeNull(); + expect(trustedServer).not.toHaveBeenCalled(); + + runner.dispatchEvent(new Event('load')); + await expect(accepted).resolves.toBe(true); + expect(slot.querySelector('span')).toBeNull(); + expect(frame.style.display).toBe(''); }); - it('contract test: declines missing, throwing, rejecting, and malformed hooks without fallback', async () => { + it('fails closed when the runner fails without clearing publisher content', async () => { const trustedServer = vi.fn(() => true); - await expect( - dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) - ).resolves.toBe(false); - - window.tsjs = { - apsNativeRenderer: { - render: () => { - throw new Error('fictional'); - }, - }, - } as typeof window.tsjs; - await expect( - dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) - ).resolves.toBe(false); - - window.tsjs = { - apsNativeRenderer: { render: () => Promise.reject(new Error('fictional')) }, - } as typeof window.tsjs; - await expect( - dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) - ).resolves.toBe(false); - - window.tsjs = { - apsNativeRenderer: { render: () => ({ accepted: false }) }, - } as typeof window.tsjs; - await expect( - dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) - ).resolves.toBe(false); + const accepted = dispatchApsRendering({ + slotId: 'fictional-slot', + renderer: descriptor(), + trustedServer, + }); + const frame = document.querySelector('#fictional-slot iframe')!; + const { runner } = nativeRunnerState(frame); - window.tsjs = { - apsNativeRenderer: { render: () => ({ accepted: 'yes' }) }, - } as typeof window.tsjs; - await expect( - dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer }) - ).resolves.toBe(false); + runner.dispatchEvent(new Event('error')); + await expect(accepted).resolves.toBe(false); expect(trustedServer).not.toHaveBeenCalled(); - expect(document.querySelector('iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot span')).not.toBeNull(); }); - it('contract test: ignores a stale acknowledgement after a replacement dispatch', async () => { - let resolveFirst: ((value: { accepted: boolean }) => void) | undefined; - const render = vi - .fn() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValueOnce({ accepted: true }); - window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; - + 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, }); - resolveFirst!({ accepted: 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('contract test: a missing hook supersedes an older pending dispatch', async () => { - let resolveFirst: ((value: { accepted: boolean }) => void) | undefined; - const render = vi.fn( - () => - new Promise<{ accepted: boolean }>((resolve) => { - resolveFirst = resolve; - }) - ); - window.tsjs = { apsNativeRenderer: { render } } as typeof window.tsjs; - + it('lets an invalid replacement cancel an older pending runner', async () => { const first = dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer: () => true, }); - delete window.tsjs!.apsNativeRenderer; const second = dispatchApsRendering({ slotId: 'fictional-slot', - renderer: descriptor(), + renderer: descriptor({ aaxResponse: 'invalid' }), trustedServer: () => true, }); - resolveFirst!({ accepted: true }); - await expect(second).resolves.toBe(false); + 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('contract test: a trusted-server dispatch supersedes an older native dispatch', async () => { - let resolveFirst: ((value: { accepted: boolean }) => void) | undefined; - window.tsjs = { - apsNativeRenderer: { - render: () => - new Promise<{ accepted: boolean }>((resolve) => { - resolveFirst = resolve; - }), - }, - } as typeof window.tsjs; + it('lets a trusted-server dispatch supersede an older native frame', async () => { const first = dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), @@ -440,87 +410,64 @@ describe('publisher-native APS hook contract tests', () => { renderer: descriptor(), trustedServer, }); - resolveFirst!({ accepted: true }); expect(second).toBe(true); expect(trustedServer).toHaveBeenCalledOnce(); await expect(first).resolves.toBe(false); + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); }); - it('contract test: contains throwing hook and acknowledgement accessors', async () => { + 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('contains throwing publisher slot mappings without falling back', async () => { const tsjs = {} as NonNullable; - Object.defineProperty(tsjs, 'apsNativeRenderer', { + Object.defineProperty(tsjs, 'divToSlotId', { get: () => { - throw new Error('fictional hook lookup failure'); + throw new Error('fictional mapping lookup failure'); }, }); window.tsjs = tsjs; + const trustedServer = vi.fn(() => true); await expect( dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }) - ).resolves.toBe(false); - - window.tsjs = { - apsNativeRenderer: { - render: () => - new Proxy( - { accepted: true }, - { - ownKeys: () => { - throw new Error('fictional acknowledgement inspection failure'); - }, - } - ), - }, - } as typeof window.tsjs; - await expect( - dispatchApsRendering({ - slotId: 'fictional-slot', + slotId: 'logical-slot', renderer: descriptor(), - trustedServer: () => true, + trustedServer, }) ).resolves.toBe(false); + expect(trustedServer).not.toHaveBeenCalled(); + expect(document.querySelector('iframe')).toBeNull(); }); - it('contract test: times out a hook and ignores its late acknowledgement', async () => { + it('times out an unacknowledged runner without clearing publisher content', async () => { vi.useFakeTimers(); try { - let resolveHook: ((value: { accepted: boolean }) => void) | undefined; - window.tsjs = { - apsNativeRenderer: { - render: () => - new Promise<{ accepted: boolean }>((resolve) => { - resolveHook = resolve; - }), - }, - } as typeof window.tsjs; - const result = dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), trustedServer: () => true, }); - await vi.advanceTimersByTimeAsync(APS_NATIVE_RENDERER_ACK_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(APS_NATIVE_RENDERER_TIMEOUT_MS); await expect(result).resolves.toBe(false); expect(vi.getTimerCount()).toBe(0); - resolveHook!({ accepted: true }); - await Promise.resolve(); - - window.tsjs = { - apsNativeRenderer: { render: () => ({ accepted: true }) }, - } as typeof window.tsjs; - await expect( - dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }) - ).resolves.toBe(true); + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot span')).not.toBeNull(); } finally { vi.useRealTimers(); } 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 7f23cd394..b8f1bfd93 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,7 +7,10 @@ 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_RENDERING_MODE_META_NAME } from '../../../src/integrations/aps/render'; +import { + APS_PREBID_CREATIVE_RUNNER_URL, + APS_RENDERING_MODE_META_NAME, +} from '../../../src/integrations/aps/render'; function enablePublisherNativeMode(): HTMLMetaElement { const marker = document.createElement('meta'); @@ -17,6 +20,26 @@ function enablePublisherNativeMode(): HTMLMetaElement { return marker; } +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]; return { @@ -3206,14 +3229,12 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); - it('contract test: delegates a server APS owner to the native hook without a Universal Creative response', async () => { + it('contract test: renders a server APS owner with the injected runner and no Universal Creative response', async () => { const renderer = apsRenderer(); - const render = vi.fn().mockResolvedValue({ accepted: true }); (window as TestWindow).tsjs.bids.homepage_header = { hb_adid: renderer.bidId, renderer, }; - (window as TestWindow).tsjs.apsNativeRenderer = { render }; const marker = enablePublisherNativeMode(); try { @@ -3229,15 +3250,17 @@ describe('installTsRenderBridge', () => { 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(render).toHaveBeenCalledOnce(); - expect(render).toHaveBeenCalledWith({ - version: 1, - slotId: 'homepage_header', - renderer, - }); + expect(native.frame.style.display).toBe(''); expect(portMessages).toEqual([]); expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); } finally { @@ -3245,14 +3268,12 @@ describe('installTsRenderBridge', () => { } }); - it('contract test: declines a server APS owner without a Universal Creative response or fallback', async () => { + it('contract test: fails a server APS runner without a Universal Creative response or fallback', async () => { const renderer = apsRenderer(); - const render = vi.fn().mockResolvedValue({ accepted: false }); (window as TestWindow).tsjs.bids.homepage_header = { hb_adid: renderer.bidId, renderer, }; - (window as TestWindow).tsjs.apsNativeRenderer = { render }; const marker = enablePublisherNativeMode(); try { @@ -3267,12 +3288,13 @@ describe('installTsRenderBridge', () => { }) as unknown as MessageEvent; bridgeListener(request); + nativeRunnerIn('div-header').runner.dispatchEvent(new Event('error')); await Promise.resolve(); await Promise.resolve(); bridgeListener(request); - expect(render).toHaveBeenCalledOnce(); expect(portMessages).toEqual([]); + expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); } finally { marker.remove(); @@ -3334,11 +3356,10 @@ describe('installTsRenderBridge', () => { foreignIframe.remove(); }); - it('contract test: declines a registered APS capability without a Universal Creative response or markUsed', async () => { + 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(); - const render = vi.fn().mockResolvedValue({ accepted: false }); (window as TestWindow).tsjs.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', @@ -3348,7 +3369,6 @@ describe('installTsRenderBridge', () => { markUsed, }, }; - (window as TestWindow).tsjs.apsNativeRenderer = { render }; const marker = enablePublisherNativeMode(); try { @@ -3363,25 +3383,25 @@ describe('installTsRenderBridge', () => { }) as unknown as MessageEvent; bridgeListener(request); + nativeRunnerIn('div-header').runner.dispatchEvent(new Event('error')); await Promise.resolve(); await Promise.resolve(); bridgeListener(request); - expect(render).toHaveBeenCalledOnce(); 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 native acceptance', async () => { + 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(); - const render = vi.fn().mockResolvedValue({ accepted: true }); (window as TestWindow).tsjs.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', @@ -3391,7 +3411,6 @@ describe('installTsRenderBridge', () => { markUsed, }, }; - (window as TestWindow).tsjs.apsNativeRenderer = { render }; const marker = enablePublisherNativeMode(); try { @@ -3407,11 +3426,13 @@ describe('installTsRenderBridge', () => { 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(render).toHaveBeenCalledOnce(); + expect(native.frame.style.display).toBe(''); expect(markUsed).toHaveBeenCalledOnce(); expect(portMessages).toEqual([]); expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 9a5f4cb7e..1479e17f8 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -36,7 +36,7 @@ debug = false # inventory_domain = "publisher.example" # inventory_page_origin = "https://www.publisher.example" allow_script_creatives = false -# Default. Set publisher_native only with a publisher-installed hook (below). +# Default. Set publisher_native only for the controlled friendly-frame experiment below. rendering_mode = "trusted_server" [auction] @@ -51,30 +51,27 @@ 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 emits the inert `` head marker selecting the publisher hook below. The marker works under a publisher CSP that blocks inline scripts. Unknown values fail configuration deserialization. +`rendering_mode` is a strict enum: `trusted_server` (the default) retains the opaque static renderer route, and `publisher_native` disables that route and emits the inert `` head marker selecting the injected friendly-frame runner below. The marker works under a publisher CSP that blocks inline scripts. Unknown values fail configuration deserialization. -### Publisher-native hook experiment +### Publisher-native runner experiment -`publisher_native` is an opt-in publisher integration seam, **not** APS compatibility proof. No public APS API was found that accepts an externally selected OpenRTB `aaxResponse` for native rendering. In particular, `apstag.setDisplayBids()` operates on APS's own `fetchBids()` state and is not an ingestion API for the exact Trusted Server-selected bid. Trusted Server does not call `apstag`, `fetchBids`, or `setDisplayBids`, mutate APS internals, or start a second auction. +`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. -Before Trusted Server JS receives a selected descriptor, the publisher must install this versioned hook: +No publisher JavaScript change is required. After validating and freezing the exact selected descriptor, Trusted Server JS: -```js -window.tsjs = window.tsjs || {} -window.tsjs.apsNativeRenderer = { - render({ version, slotId, renderer }) { - // version is exactly 1; renderer is frozen and fully validated. - // Render only this exact selected descriptor through publisher-owned logic. - return { accepted: true } - }, -} -``` +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. -The hook receives exactly `{ version: 1, slotId, renderer }`. It must return or resolve an object with `accepted: boolean`; `reason?: string` is allowed for fictional-safe diagnostics. Missing hooks, throws, rejected promises, malformed acknowledgements, `{ accepted: false }`, and acknowledgements that take longer than 10 seconds visibly decline the bid. They never fall back to the Trusted Server iframe or send a Universal Creative renderer response. A newer dispatch makes an older acknowledgement stale and ignored; late acknowledgements after the timeout are also ignored. Hook implementations own cancellation of any already-started side effect and actual render completion. +Unlike `trusted_server` mode, this friendly frame deliberately has no opaque-origin sandbox. The fixed APS runner and its creative execute with the behavior of a publisher-origin integration, so `publisher_native` has a larger security surface—especially when `allow_script_creatives = true`. Use only a controlled cohort, and ensure publisher CSP permits the APS runner and required creative resources. -For a client-side Prebid APS capability, Trusted Server consumes the one-shot capability before delegation and calls `markWinningBidAsUsed` only after `accepted: true`. For server/GPT ownership, it similarly claims the slot/ad ID before invoking the hook. This prevents native and Trusted Server rendering from both owning the same response, but an accepting hook is responsible for real rendering semantics. +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 publisher-native APS demand for every `publisher_native` cohort. Otherwise native APS demand and this server-selected bid can duplicate demand. Validate a controlled real publisher/account setup with the APS account team before any production rollout. +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. @@ -194,11 +191,11 @@ It deliberately omits `allow-same-origin`, so APS and bidder execution remains b ### Direct `/auction` -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 instead calls the explicit publisher hook and creates no Trusted Server iframe. 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`. In `publisher_native` mode the ownership-checked bridge calls the publisher hook without sending a Universal Creative renderer response; in `trusted_server` mode it uses the static dynamic renderer described below. 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`. In `publisher_native` mode the ownership-checked bridge resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response; in `trusted_server` mode it uses the static dynamic renderer described below. 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 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. @@ -242,11 +239,12 @@ Use fictional values in source-controlled configuration and fixtures. Supply con 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. 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 @@ -262,12 +260,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 mode `` marker is present, the slot receives a hidden friendly iframe, and `https://client.aps.amazon-adsystem.com/prebid-creative.js` is not blocked by CSP. The static renderer route is intentionally absent in this mode. - 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 6e844e385..cedeb5f6e 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -156,7 +156,7 @@ debug = false # 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 -# when the publisher installs the documented tsjs.apsNativeRenderer hook. +# for the controlled publisher-origin friendly-frame experiment. rendering_mode = "trusted_server" [integrations.google_tag_manager] From 63370fc13c8912349cea3144fcd3f4715c110e54 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 20 Aug 2026 15:34:54 -0500 Subject: [PATCH 3/5] Prevent APS creative frame scrollbars --- .../src/integrations/aps.rs | 5 ++++ .../browser/tests/shared/aps-renderer.spec.ts | 26 +++++++++++++++++++ .../lib/src/integrations/aps/render.ts | 24 +++++++++++++++++ .../lib/test/integrations/aps/render.test.ts | 9 +++++++ 4 files changed, 64 insertions(+) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index d6037ce5d..aba550097 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -50,6 +50,7 @@ const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-po const APS_RENDERER_DOCUMENT: &str = r#" + ' + ); + 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', () => { - beforeEach(() => { + let dispatchApsRendering: typeof dispatchDefaultApsRendering; + + beforeEach(async () => { + vi.resetModules(); document.body.innerHTML = '
existing
'; - enablePublisherNativeMode(); + 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(() => { - disablePublisherNativeMode(); delete window.tsjs; vi.restoreAllMocks(); document.body.innerHTML = ''; @@ -307,11 +331,6 @@ describe('publisher-native APS runner contract tests', () => { it('queues the exact selected response for the fixed APS runner and commits on load', async () => { const trustedServer = vi.fn(() => true); - const unrelatedMarker = document.createElement('meta'); - unrelatedMarker.name = APS_RENDERING_MODE_META_NAME; - unrelatedMarker.content = 'trusted_server'; - document.head.appendChild(unrelatedMarker); - const accepted = dispatchApsRendering({ slotId: 'fictional-slot', renderer: descriptor(), @@ -405,41 +424,66 @@ describe('publisher-native APS runner contract tests', () => { expect(document.querySelector('#fictional-slot span')).not.toBeNull(); }); - it('lets a trusted-server dispatch supersede an older native frame', async () => { - const first = dispatchApsRendering({ - slotId: 'fictional-slot', + 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')); - disablePublisherNativeMode(); - const trustedServer = vi.fn(() => true); - const second = dispatchApsRendering({ - slotId: 'fictional-slot', + 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(), - trustedServer, + 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')); - expect(second).toBe(true); - expect(trustedServer).toHaveBeenCalledOnce(); - await expect(first).resolves.toBe(false); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + 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('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; + 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: 'homepage_header', + slotId: 'div-header-', renderer: descriptor(), + source, trustedServer: () => true, }); - const frame = document.querySelector('#div-header iframe')!; + 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 span')).toBeNull(); + 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 () => { 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 cf8c5048f..eeb2ee6a6 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 @@ -9,15 +9,19 @@ 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_META_NAME, + APS_RENDERING_MODE_ATTRIBUTE_NAME, } from '../../../src/integrations/aps/render'; -function enablePublisherNativeMode(): HTMLMetaElement { - const marker = document.createElement('meta'); - marker.name = APS_RENDERING_MODE_META_NAME; - marker.content = 'publisher_native'; - document.head.appendChild(marker); - return marker; +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): { @@ -2881,6 +2885,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) { @@ -2941,6 +2946,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( @@ -2955,6 +2963,7 @@ describe('installTsRenderBridge', () => { ); await import('../../../src/integrations/gpt/index'); addSpy.mockRestore(); + currentScriptSpy?.mockRestore(); expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); return bridgeListener!; @@ -3442,6 +3451,52 @@ describe('installTsRenderBridge', () => { } }); + 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 1479e17f8..00cf94c44 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -51,7 +51,7 @@ 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 emits the inert `` head marker selecting the injected friendly-frame runner below. The marker works under a publisher CSP that blocks inline scripts. Unknown values fail configuration deserialization. +`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 @@ -195,7 +195,9 @@ In `trusted_server` mode, the TSJS auction client validates the typed renderer d ### GAM and Universal Creative -For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. In `publisher_native` mode the ownership-checked bridge resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response; in `trusted_server` mode it uses the static dynamic renderer described below. 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. @@ -233,6 +235,8 @@ 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. @@ -242,7 +246,7 @@ Use fictional values in source-controlled configuration and fixtures. Supply con 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. 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. Confirm the publisher CSP permits the runner but does not need to permit inline Trusted Server scripts. +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. From 28a1b7b7ead90aafbb874564226ce62b41caf056 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 24 Aug 2026 12:28:03 -0500 Subject: [PATCH 5/5] Harden APS renderer handoff behavior Invalid descriptors in default mode could cancel valid in-flight opaque renderer frames because the shared dispatcher cancelled before validation. Restrict early supersession to native mode so default behavior remains stable. Preserve the no-referrer policy in friendly frames and align browser coverage and operator guidance with the actual script-tag mode signal. --- .../browser/tests/shared/aps-renderer.spec.ts | 11 ++++-- .../lib/src/integrations/aps/render.ts | 10 +++-- .../lib/test/integrations/aps/render.test.ts | 39 +++++++++++++++++++ docs/guide/integrations/aps.md | 4 +- 4 files changed, 55 insertions(+), 9 deletions(-) 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 1604f2076..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,8 +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://client.aps.amazon-adsystem.com/trusted-server-core.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 = @@ -971,6 +970,8 @@ parent.postMessage(JSON.stringify({ 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({ @@ -981,6 +982,7 @@ parent.postMessage(JSON.stringify({ }); await page.route(RUNNER_URL, async (route) => { runnerRequests += 1; + runnerReferrer = route.request().headers()["referer"]; await route.fulfill({ status: 200, contentType: "application/javascript", @@ -988,6 +990,7 @@ parent.postMessage(JSON.stringify({ }); }); await page.route(IFRAME_CREATIVE_URL, async (route) => { + creativeReferrer = route.request().headers()["referer"]; await route.fulfill({ status: 200, contentType: "text/html", @@ -1025,7 +1028,7 @@ parent.postMessage(JSON.stringify({ contentType: "text/html", headers: { "Content-Security-Policy": - "default-src 'none'; script-src https://client.aps.amazon-adsystem.com https://creative.example; connect-src 'self'; frame-src https://creative.example", + "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
`, @@ -1084,6 +1087,8 @@ parent.postMessage(JSON.stringify({ 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!; 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 377713ba3..adec0b036 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -388,9 +388,10 @@ export function dispatchApsRendering({ source, trustedServer, }: DispatchApsRenderingOptions): boolean | Promise { - // Every attempt supersedes a pending frame for this slot, including an invalid - // replacement that fails before a new frame can be created. - cancelPendingApsRendering(slotId, source); + // 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); @@ -539,7 +540,8 @@ function renderApsPublisherNative({ frameDocument.open(); frameDocument.write( - '' + '' + + '' ); frameDocument.close(); prepareApsRunnerDocument(frameWindow, frameDocument); 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 4157e492b..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 @@ -352,6 +352,9 @@ describe('publisher-native APS runner contract tests', () => { 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'); @@ -537,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/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 00cf94c44..bde759c45 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -67,7 +67,7 @@ No publisher JavaScript change is required. After validating and freezing the ex 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. The fixed APS runner and its creative execute with the behavior of a publisher-origin integration, so `publisher_native` has a larger security surface—especially when `allow_script_creatives = true`. Use only a controlled cohort, and ensure publisher CSP permits the APS runner and required creative resources. +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. @@ -265,7 +265,7 @@ Use fictional values in source-controlled configuration and fixtures. Supply con ### Winner targets but does not render - 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 mode `` marker is present, the slot receives a hidden friendly iframe, and `https://client.aps.amazon-adsystem.com/prebid-creative.js` is not blocked by CSP. The static renderer route is intentionally absent in this mode. +- 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 publisher APS auction is trying to handle the same cohort.