(json!({
+ "account_id": "example-account",
+ "rendering_mode": "unsupported"
+ }))
+ .is_err(),
+ "should reject an unknown APS rendering mode"
+ );
for endpoint in [
"http://aps.example/e/pb/bid",
"https://",
@@ -2319,7 +2378,9 @@ mod tests {
#[test]
fn registers_and_serves_only_static_renderer_route() {
- let integration = ApsRendererIntegration;
+ let integration = ApsRendererIntegration {
+ rendering_mode: ApsRenderingMode::TrustedServer,
+ };
let routes = integration.routes();
assert_eq!(routes.len(), 1, "should register one route");
assert_eq!(routes[0].method, Method::GET);
@@ -2374,9 +2435,103 @@ mod tests {
assert_eq!(registration.integration_id, APS_INTEGRATION_ID);
assert_eq!(registration.proxies.len(), 1);
+ assert_eq!(registration.head_injectors.len(), 1);
+ let document_state = IntegrationDocumentState::default();
+ let context = IntegrationHtmlContext {
+ request_host: "publisher.example",
+ request_scheme: "https",
+ origin_host: "origin.example",
+ document_state: &document_state,
+ };
+ assert!(
+ registration.head_injectors[0]
+ .head_inserts(&context)
+ .is_empty(),
+ "should not inject a native-mode head marker by default"
+ );
+ assert!(
+ registration.head_injectors[0]
+ .tsjs_script_tag_attributes()
+ .is_empty(),
+ "should not authorize native rendering by default"
+ );
assert!(registration.js_disabled);
}
+ #[test]
+ fn publisher_native_config_registers_runner_mode_without_renderer_route() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ APS_INTEGRATION_ID,
+ &json!({
+ "enabled": true,
+ "account_id": "example-account",
+ "rendering_mode": "publisher_native"
+ }),
+ )
+ .expect("should insert native APS config");
+
+ let registration = register(&settings)
+ .expect("should register APS")
+ .expect("should return enabled registration");
+ assert!(
+ registration.proxies.is_empty(),
+ "should not register the static renderer"
+ );
+ assert_eq!(registration.head_injectors.len(), 1);
+
+ let integration = ApsRendererIntegration {
+ rendering_mode: ApsRenderingMode::PublisherNative,
+ };
+ assert!(
+ integration.routes().is_empty(),
+ "should expose no renderer route"
+ );
+ let document_state = IntegrationDocumentState::default();
+ let context = IntegrationHtmlContext {
+ request_host: "publisher.example",
+ request_scheme: "https",
+ origin_host: "origin.example",
+ document_state: &document_state,
+ };
+ assert!(
+ integration.head_inserts(&context).is_empty(),
+ "should not inject a forgeable native-mode marker"
+ );
+ assert_eq!(
+ integration.tsjs_script_tag_attributes(),
+ vec![("data-ts-aps-rendering-mode", "publisher_native")],
+ "should authorize native mode on the publisher bundle tag"
+ );
+ }
+
+ #[test]
+ fn publisher_native_script_creatives_remain_available_for_controlled_validation() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ APS_INTEGRATION_ID,
+ &json!({
+ "enabled": true,
+ "account_id": "example-account",
+ "allow_script_creatives": true,
+ "rendering_mode": "publisher_native"
+ }),
+ )
+ .expect("should insert native APS script config");
+
+ let providers = register_providers(&settings).expect("should register APS provider");
+
+ assert_eq!(
+ providers.len(),
+ 1,
+ "should retain the controlled experiment"
+ );
+ }
+
#[test]
fn config_without_enabled_does_not_register_provider_or_renderer() {
let mut settings = create_test_settings();
@@ -2429,6 +2584,10 @@ mod tests {
assert!(APS_RENDERER_DOCUMENT.contains("message.nonce!==expected"));
assert!(APS_RENDERER_DOCUMENT.contains("prebid/creative/render"));
assert!(APS_RENDERER_DOCUMENT.contains("window._aps instanceof Map"));
+ assert!(
+ APS_RENDERER_DOCUMENT
+ .contains("html,body{margin:0;padding:0}body>iframe{display:block}")
+ );
assert!(APS_RENDERER_DOCUMENT.contains("store:new Map([['listeners',new Map()]])"));
assert!(APS_RENDERER_DOCUMENT.contains("account.queue.push(new CustomEvent"));
assert!(
diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts
index 6c0301e2c..fce505d42 100644
--- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts
+++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts
@@ -4,6 +4,7 @@ import { expect, test, type Page } from "@playwright/test";
import { runtimeUrl } from "../../helpers/state.js";
const RUNNER_URL = "https://client.aps.amazon-adsystem.com/prebid-creative.js";
+const PUBLISHER_CORE_URL = "https://tsjs.example/trusted-server-core.js";
const IFRAME_CREATIVE_URL = "https://creative.example/iframe";
const SCRIPT_CREATIVE_URL = "https://creative.example/script.js";
const SANDBOX =
@@ -155,6 +156,9 @@ const FAKE_RUNNER = `(function(){
if (bid.ext.tagtype === 'iframe') {
var frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
+ frame.width = String(bid.w);
+ frame.height = String(bid.h);
+ frame.style.border = '0';
frame.src = bid.ext.creativeurl;
document.body.appendChild(frame);
} else {
@@ -192,7 +196,7 @@ const SCRIPT_CREATIVE = `(function(){
}, '*');
})();`;
-test.describe("APS opaque renderer", () => {
+test.describe("APS rendering", () => {
test("renders a trustedServer adapter bid using Prebid's generated GAM ad ID", async ({
page,
}) => {
@@ -956,4 +960,167 @@ parent.postMessage(JSON.stringify({
1,
);
});
+
+ test("renders publisher-native mode through the injected friendly-frame runner", async ({
+ page,
+ }) => {
+ const publisherOrigin = "https://publisher.example";
+ const auctionUrl = `${publisherOrigin}/auction`;
+ const testUrl = `${publisherOrigin}/aps-publisher-native-test`;
+ const renderer = descriptor("iframe");
+ const coreBundle = readFileSync(clientAuctionBundlePaths().core, "utf8");
+ let runnerRequests = 0;
+ let runnerReferrer: string | undefined;
+ let creativeReferrer: string | undefined;
+
+ await page.route(PUBLISHER_CORE_URL, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/javascript",
+ body: coreBundle,
+ });
+ });
+ await page.route(RUNNER_URL, async (route) => {
+ runnerRequests += 1;
+ runnerReferrer = route.request().headers()["referer"];
+ await route.fulfill({
+ status: 200,
+ contentType: "application/javascript",
+ body: FAKE_RUNNER,
+ });
+ });
+ await page.route(IFRAME_CREATIVE_URL, async (route) => {
+ creativeReferrer = route.request().headers()["referer"];
+ await route.fulfill({
+ status: 200,
+ contentType: "text/html",
+ body: IFRAME_CREATIVE,
+ });
+ });
+ await page.route(auctionUrl, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ id: "fictional-native-auction",
+ seatbid: [
+ {
+ seat: "aps",
+ bid: [
+ {
+ id: renderer.bidId,
+ impid: "publisher-native-slot",
+ price: 1.23,
+ w: renderer.width,
+ h: renderer.height,
+ ext: { trusted_server: { renderer } },
+ },
+ ],
+ },
+ ],
+ ext: {},
+ }),
+ });
+ });
+ await page.route(testUrl, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "text/html",
+ headers: {
+ "Content-Security-Policy":
+ "default-src 'none'; script-src https://tsjs.example https://client.aps.amazon-adsystem.com https://creative.example; connect-src 'self'; frame-src https://creative.example",
+ },
+ body: `
+existing publisher content
`,
+ });
+ });
+
+ await page.goto(testUrl);
+ await page.evaluate(async (scriptUrl) => {
+ await new Promise((resolveScript, rejectScript) => {
+ const script = document.createElement("script");
+ script.setAttribute(
+ "data-ts-aps-rendering-mode",
+ "publisher_native",
+ );
+ script.src = scriptUrl;
+ script.addEventListener("load", () => resolveScript(), {
+ once: true,
+ });
+ script.addEventListener(
+ "error",
+ () => rejectScript(new Error("TSJS core failed to load")),
+ { once: true },
+ );
+ document.head.appendChild(script);
+ });
+ }, PUBLISHER_CORE_URL);
+ await page.evaluate(() => {
+ const tsjs = (
+ window as unknown as {
+ tsjs: {
+ addAdUnits(units: Array>): void;
+ requestAds(): void;
+ };
+ }
+ ).tsjs;
+ tsjs.addAdUnits([
+ {
+ code: "publisher-native-slot",
+ mediaTypes: { banner: { sizes: [[300, 250]] } },
+ bids: [],
+ },
+ ]);
+ tsjs.requestAds();
+ });
+
+ await expect.poll(() => runnerRequests).toBe(1);
+ const frame = page.locator("#publisher-native-slot > iframe");
+ await expect(frame).toHaveCount(1);
+ await expect(frame).toBeVisible();
+ expect(await frame.getAttribute("sandbox")).toBeNull();
+ await expect(
+ frame
+ .contentFrame()
+ .locator(`iframe[src="${IFRAME_CREATIVE_URL}"]`),
+ ).toHaveCount(1);
+ await expect(
+ page.locator("#publisher-native-slot .existing"),
+ ).toHaveCount(0);
+ expect(runnerReferrer).toBeUndefined();
+ expect(creativeReferrer).toBeUndefined();
+ expect(
+ await frame.evaluate((element: HTMLIFrameElement) => {
+ const document = element.contentDocument!;
+ const creative = document.body.querySelector("iframe")!;
+ return {
+ bodyMargin: getComputedStyle(document.body).margin,
+ bodyPadding: getComputedStyle(document.body).padding,
+ creativeDisplay: getComputedStyle(creative).display,
+ clientWidth: document.documentElement.clientWidth,
+ clientHeight: document.documentElement.clientHeight,
+ scrollWidth: document.documentElement.scrollWidth,
+ scrollHeight: document.documentElement.scrollHeight,
+ };
+ }),
+ ).toEqual({
+ bodyMargin: "0px",
+ bodyPadding: "0px",
+ creativeDisplay: "block",
+ clientWidth: 300,
+ clientHeight: 250,
+ scrollWidth: 300,
+ scrollHeight: 250,
+ });
+ expect(
+ await page
+ .locator("#publisher-native-slot")
+ .evaluate(
+ (slot) =>
+ slot.querySelectorAll(
+ 'iframe[src*="/integrations/aps/renderer"]',
+ ).length,
+ ),
+ ).toBe(0);
+ });
});
diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts
index df3fee6a6..c05070a9d 100644
--- a/crates/trusted-server-js/lib/src/core/request.ts
+++ b/crates/trusted-server-js/lib/src/core/request.ts
@@ -1,5 +1,5 @@
// Request orchestration for tsjs: unified auction endpoint with iframe-based creative rendering.
-import { renderApsCreative } from '../integrations/aps/render';
+import { dispatchApsRendering, renderApsCreative } from '../integrations/aps/render';
import { buildAdRequest, sendAuction } from './auction';
import { collectContext } from './context';
@@ -52,7 +52,13 @@ export function requestAds(
for (const bid of bids) {
if (!bid.impid) continue;
if (bid.renderer) {
- renderApsCreative({ slotId: bid.impid, renderer: bid.renderer });
+ void Promise.resolve(
+ dispatchApsRendering({
+ slotId: bid.impid,
+ renderer: bid.renderer,
+ trustedServer: (renderer) => renderApsCreative({ slotId: bid.impid, renderer }),
+ })
+ );
continue;
}
if (!bid.adm) {
diff --git a/crates/trusted-server-js/lib/src/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts
index 85f17adf9..adec0b036 100644
--- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts
+++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts
@@ -1,7 +1,12 @@
import { log } from '../../core/log';
+import { findSlot } from '../../core/render';
import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types';
export const APS_RENDERER_PATH = '/integrations/aps/renderer';
+export const APS_RENDERING_MODE_ATTRIBUTE_NAME = 'data-ts-aps-rendering-mode';
+export const APS_PREBID_CREATIVE_RUNNER_URL =
+ 'https://client.aps.amazon-adsystem.com/prebid-creative.js';
+export const APS_NATIVE_RENDERER_TIMEOUT_MS = 10_000;
export const APS_RENDERER_SANDBOX =
'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation';
export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 4;
@@ -38,6 +43,92 @@ type ValidatedRendererCacheEntry = {
renderer: ApsRendererV1;
};
const validatedRendererCache = new WeakMap