diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs
index 4f45fbeb9c..2c8cacd32d 100644
--- a/crates/trusted-server-core/src/integrations/aps.rs
+++ b/crates/trusted-server-core/src/integrations/aps.rs
@@ -78,42 +78,54 @@ const APS_RENDERER_DOCUMENT: &str = r#"
var match=/^#tsaps=([A-Za-z0-9_-]{22,128})$/.exec(location.hash);
var expected=match&&match[1];
try{history.replaceState(null,'',location.pathname+location.search);}catch(_error){}
-if(!expected)return;
+var reported=false;
+function report(reason,nonce){
+ if(reported)return;
+ reported=true;
+ try{parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:nonce,reason:reason},'*');}catch(_error){}
+}
+if(!expected){report('bad_hash');return;}
function keys(value,expectedKeys){
if(!value||typeof value!=='object'||Array.isArray(value))return false;
var actual=Object.keys(value).sort();
return actual.length===expectedKeys.length&&actual.every(function(key,index){return key===expectedKeys[index];});
}
-function validRenderer(renderer){
+function rendererProblem(renderer){
if(!keys(renderer,['aaxResponse','accountId','bidId','creativeId','creativeUrl','height','tagType','type','version','width'])&&
- !keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return false;
- if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return false;
- if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return false;
- if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return false;
- if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return false;
- if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return false;
- if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return false;
+ !keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return 'descriptor_keys';
+ if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return 'descriptor_fields';
+ if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return 'descriptor_fields';
+ if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return 'descriptor_fields';
+ if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return 'descriptor_fields';
+ if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return 'descriptor_fields';
+ if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return 'descriptor_fields';
try{
var url=new URL(renderer.creativeUrl);
- if(url.protocol!=='https:'||url.username||url.password)return false;
+ if(url.protocol!=='https:'||url.username||url.password)return 'descriptor_envelope';
var binary=atob(renderer.aaxResponse);
- if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return false;
+ if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return 'descriptor_envelope';
var bytes=Uint8Array.from(binary,function(character){return character.charCodeAt(0);});
var decoded=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));
- if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return false;
+ if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return 'descriptor_envelope';
var seat=decoded.seatbid[0];
- if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return false;
+ if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return 'descriptor_envelope';
var bid=seat.bid[0];
- if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return false;
- return bid.id===renderer.bidId&&bid.w===renderer.width&&bid.h===renderer.height&&
+ if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return 'descriptor_envelope';
+ if(bid.id===renderer.bidId&&bid.w===renderer.width&&bid.h===renderer.height&&
bid.ext.creativeurl===renderer.creativeUrl&&bid.ext.tagtype===renderer.tagType&&
- typeof bid.price==='number'&&Number.isFinite(bid.price)&&bid.price>=0;
- }catch(_error){return false;}
+ typeof bid.price==='number'&&Number.isFinite(bid.price)&&bid.price>=0)return undefined;
+ return 'descriptor_envelope';
+ }catch(_error){return 'descriptor_envelope';}
}
function receive(event){
+ // The source check stays first: a frame that cannot be reached by an
+ // unrelated sender cannot have its single report consumed by one either.
if(event.source!==parent)return;
var message=event.data;
- if(!keys(message,['nonce','renderer'])||message.nonce!==expected||!validRenderer(message.renderer))return;
+ if(!keys(message,['nonce','renderer']))return;
+ if(message.nonce!==expected){report('nonce_mismatch',expected);return;}
+ var problem=rendererProblem(message.renderer);
+ if(problem){report(problem,message.nonce);return;}
removeEventListener('message',receive);
var acceptedNonce=expected;
expected='';
@@ -128,7 +140,7 @@ function receive(event){
var script=document.createElement('script');
script.src='https://client.aps.amazon-adsystem.com/prebid-creative.js';
script.onload=function(){parent.postMessage({message:'trusted-server/aps/renderer-ready',nonce:acceptedNonce},'*');};
- script.onerror=function(){parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:acceptedNonce},'*');};
+ script.onerror=function(){report('amazon_script_error',acceptedNonce);};
document.head.appendChild(script);
}
addEventListener('message',receive);
@@ -3308,4 +3320,52 @@ mod tests {
assert!(APS_RENDERER_CSP.contains("sandbox allow-forms"));
assert!(!APS_RENDERER_CSP.contains("allow-same-origin"));
}
+
+ /// Guards the properties of the failure report that a substring check can
+ /// actually establish.
+ ///
+ /// The browser spec evaluates the document and asserts `bad_hash`,
+ /// `nonce_mismatch`, and `descriptor_keys`; `descriptor_fields`,
+ /// `descriptor_envelope`, and `amazon_script_error` are not executed
+ /// anywhere yet. The assertions kept here are the ones that stay meaningful
+ /// against a string: that a reason is a fixed category rather than
+ /// attacker-influenced content, that reporting is one-shot, and that the
+ /// frame never answers the sender.
+ #[test]
+ fn renderer_document_reports_a_fixed_reason_category() {
+ // A reason is a literal category, never a copy of the rejected input.
+ assert!(
+ !APS_RENDERER_DOCUMENT.contains("JSON.stringify(renderer)"),
+ "should never echo the rejected descriptor"
+ );
+ assert!(
+ !APS_RENDERER_DOCUMENT.contains("reason:message"),
+ "should never echo the received message as a reason"
+ );
+
+ // Reporting is one-shot, so a sender cannot flood the parent.
+ assert!(
+ APS_RENDERER_DOCUMENT.contains("if(reported)return"),
+ "should report at most one reason per frame"
+ );
+
+ // The report goes to the parent, never back to the sender.
+ assert!(
+ !APS_RENDERER_DOCUMENT.contains("event.source.postMessage"),
+ "should never answer the sender"
+ );
+
+ // A non-parent sender is still dropped before any reporting happens, so
+ // an unrelated window cannot consume the frame's single report.
+ let source_check = APS_RENDERER_DOCUMENT
+ .find("event.source!==parent")
+ .expect("should check the sender before handling a message");
+ let nonce_report = APS_RENDERER_DOCUMENT
+ .find("report('nonce_mismatch'")
+ .expect("should report a nonce mismatch");
+ assert!(
+ source_check < nonce_report,
+ "should check the sender before reporting anything"
+ );
+ }
}
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 e7ee7c1c7a..acccd7ba81 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
@@ -18,6 +18,27 @@ const PUC_BANNER = readFileSync(
"utf8",
);
+/**
+ * Read the renderer version from its declaration rather than restating it.
+ *
+ * The constant is bumped whenever the Universal Creative source changes, so a
+ * literal here turns every such change into an unrelated-looking failure in
+ * this spec.
+ */
+function universalCreativeRendererVersion(): number {
+ const source = readFileSync(
+ resolve(TSJS_CRATE, "lib/src/integrations/aps/render.ts"),
+ "utf8",
+ );
+ const match = /APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = (\d+)/.exec(source);
+ if (!match) {
+ throw new Error(
+ "could not read APS_UNIVERSAL_CREATIVE_RENDERER_VERSION from render.ts",
+ );
+ }
+ return Number(match[1]);
+}
+
function clientAuctionBundlePaths() {
const manifestPath = resolve(TSJS_CRATE, "dist/prebid/manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
@@ -369,6 +390,130 @@ window.ucTag.renderAd(document, { adId: ${JSON.stringify(adId)}, pubUrl: ${JSON.
);
});
+ // Only a real browser shows which realm a relay comes from and which slot
+ // the authenticated frame resolves to, so this is the one test that can
+ // prove a reason reaches diagnostics end to end. The renderer route is made
+ // to fail here: the frame loads, never acknowledges, and Universal Creative
+ // reports its own timeout through the creative-realm helper.
+ test("relays an APS render failure reason to diagnostics through real PUC", async ({
+ page,
+ }) => {
+ const adId = "fictional-relay-ad-id";
+ const publisherOrigin = new URL(runtimeUrl("/")).origin;
+ const outerCreativeUrl = runtimeUrl("/fictional-puc-relay-shell");
+ const apsRenderer = descriptor("iframe");
+
+ await page.route(runtimeUrl("/aps-puc-relay-test"), (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "text/html",
+ body: '
',
+ }),
+ );
+ await page.route(outerCreativeUrl, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "text/html",
+ body: ``,
+ }),
+ );
+ // The renderer document never answers, so the render times out and the
+ // Universal Creative source reports `frame_timeout`.
+ await page.route(runtimeUrl("/integrations/aps/renderer"), (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "text/html",
+ body: "silent renderer",
+ }),
+ );
+
+ await page.goto(runtimeUrl("/aps-puc-relay-test"));
+ await page.addScriptTag({ path: clientAuctionBundlePaths().gpt });
+ await page.evaluate(
+ ({ outerUrl, selectedAdId, renderer }) => {
+ const typedWindow = window as unknown as {
+ tsjs: Record;
+ recordedFailures: Array<{
+ attemptId: number;
+ reason: string;
+ }>;
+ };
+ typedWindow.recordedFailures = [];
+ typedWindow.tsjs = {
+ bids: {
+ "aps-slot": {
+ hb_adid: selectedAdId,
+ hb_bidder: "aps",
+ hb_pb: "1.23",
+ renderer,
+ },
+ },
+ adSlots: [
+ {
+ id: "aps-slot",
+ // A configured prefix, not the resolved DOM id, so
+ // the slot has to come from the authenticated frame.
+ div_id: "div-",
+ gam_unit_path: "/fictional/aps",
+ formats: [[300, 250]],
+ },
+ ],
+ divToSlotId: { "div-aps": "aps-slot" },
+ gptDiagnosticsRecorder: {
+ recordTrustedServerOpportunity: () => undefined,
+ recordTrustedServerCreativeRequest: () => 1,
+ recordTrustedServerCreativeResponse: () => undefined,
+ recordTrustedServerCreativeFailure: (
+ attemptId: number,
+ reason: string,
+ ) =>
+ typedWindow.recordedFailures.push({
+ attemptId,
+ reason,
+ }),
+ },
+ };
+
+ const locator = document.createElement("iframe");
+ locator.name = "__pb_locator__";
+ document.body.appendChild(locator);
+
+ const slot = document.getElementById("div-aps")!;
+ const frame = document.createElement("iframe");
+ frame.id = "google_ads_iframe_fictional_relay_0";
+ frame.width = "1";
+ frame.height = "1";
+ frame.src = outerUrl;
+ slot.appendChild(frame);
+ },
+ {
+ outerUrl: outerCreativeUrl,
+ selectedAdId: adId,
+ renderer: apsRenderer,
+ },
+ );
+
+ // The Universal Creative timeout is 10s, so allow for it.
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ () =>
+ (
+ window as unknown as {
+ recordedFailures: Array<{
+ reason: string;
+ }>;
+ }
+ ).recordedFailures,
+ ),
+ { timeout: 20_000 },
+ )
+ .toEqual([{ attemptId: 1, reason: "aps_frame_timeout" }]);
+ });
+
test("renders a trustedServer adapter bid using Prebid's generated GAM ad ID", async ({
page,
}) => {
@@ -566,7 +711,7 @@ parent.postMessage(JSON.stringify({
expect.objectContaining({
message: "Prebid Response",
adId: result.acceptedAdId,
- rendererVersion: 4,
+ rendererVersion: universalCreativeRendererVersion(),
apsRenderer,
}),
);
@@ -903,6 +1048,51 @@ parent.postMessage(JSON.stringify({
expect(runnerRequests).toBe(requestsBeforeInvalid);
await expect(page.locator("#malformed-slot .existing")).toHaveCount(1);
+ // The three rejections above are the only place the renderer document's
+ // wire names are checked by executing it, rather than by matching
+ // substrings in the Rust source. `nonce_mismatch` in particular is only
+ // useful if the frame echoes the nonce its parent expects, so assert the
+ // nonce as well as the reason.
+ const reportedFailures = await page.evaluate(() =>
+ (
+ window as unknown as {
+ apsMessages: Array<{
+ slotId: string;
+ data: Record;
+ }>;
+ }
+ ).apsMessages
+ .filter(
+ ({ data }) =>
+ data["message"] ===
+ "trusted-server/aps/renderer-failed",
+ )
+ .map(({ slotId, data }) => ({
+ slotId,
+ reason: data["reason"],
+ nonce: data["nonce"],
+ })),
+ );
+ expect(reportedFailures).toEqual(
+ expect.arrayContaining([
+ {
+ slotId: "mismatch-slot",
+ reason: "nonce_mismatch",
+ nonce: validNonce,
+ },
+ {
+ slotId: "missing-fragment-slot",
+ reason: "bad_hash",
+ nonce: undefined,
+ },
+ {
+ slotId: "malformed-slot",
+ reason: "descriptor_keys",
+ nonce: wrongNonce,
+ },
+ ]),
+ );
+
const scriptNonce = "0123456789abcdefghijkl";
await page.evaluate(
({ renderer, nonce }) => {
diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts
index 3407d487a0..f5459be7bd 100644
--- a/crates/trusted-server-js/lib/src/core/types.ts
+++ b/crates/trusted-server-js/lib/src/core/types.ts
@@ -181,12 +181,42 @@ export type GptDiagnosticsTrustedServerOpportunity =
| 'unrenderable_candidate'
| 'no_candidate';
+/**
+ * Every safe failure category observed while obtaining or posting creative markup.
+ *
+ * This array is the single source of truth: `GptDiagnosticsCreativeFailure` is
+ * derived from it, and the diagnostics store validates against it at runtime.
+ * Adding a member here is sufficient for the store to accept it; a
+ * compile-time union and a hand-written runtime allowlist would otherwise
+ * drift, and a dropped reason is silent. An `aps_` member also needs a wire
+ * name in the render path's reason map to be reachable, and every member needs
+ * a case in the overlay's exhaustive switch, which is a compile error by design.
+ *
+ * The `aps_` members cover the APS render path, where a blank slot is otherwise
+ * indistinguishable from a filled one: Ad Manager reports a non-empty 1x1
+ * render whether or not the creative ever drew.
+ */
+export const GPT_DIAGNOSTICS_CREATIVE_FAILURES = [
+ 'missing_render_source',
+ 'cache_fetch_failed',
+ 'invalid_cache_payload',
+ 'response_post_failed',
+ // Reported by the sandboxed renderer document and relayed by the creative.
+ 'aps_bad_hash',
+ 'aps_nonce_mismatch',
+ 'aps_descriptor_keys',
+ 'aps_descriptor_fields',
+ 'aps_descriptor_envelope',
+ 'aps_runner_script_error',
+ // Observed by the Universal Creative source around its renderer frame.
+ 'aps_frame_timeout',
+ 'aps_frame_load_error',
+ 'aps_frame_reported_failure',
+ 'aps_unknown',
+] as const;
+
/** A safe failure category observed while obtaining or posting creative markup. */
-export type GptDiagnosticsCreativeFailure =
- | 'missing_render_source'
- | 'cache_fetch_failed'
- | 'invalid_cache_payload'
- | 'response_post_failed';
+export type GptDiagnosticsCreativeFailure = (typeof GPT_DIAGNOSTICS_CREATIVE_FAILURES)[number];
/** Delivery evidence derived for a GPT request cycle. */
export type GptDiagnosticsDelivery =
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 d794b86f33..6cecf7a1f8 100644
--- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts
+++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts
@@ -1,6 +1,11 @@
import { log } from '../../core/log';
import { findSlot } from '../../core/render';
-import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types';
+import type {
+ ApsPrebidRendererEntry,
+ ApsRendererV1,
+ GptDiagnosticsCreativeFailure,
+ TsjsApi,
+} from '../../core/types';
export const APS_RENDERER_PATH = '/integrations/aps/renderer';
export const APS_RENDERING_MODE_ATTRIBUTE_NAME = 'data-ts-aps-rendering-mode';
@@ -9,7 +14,7 @@ export const APS_PREBID_CREATIVE_RUNNER_URL =
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;
+export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 5;
const MAX_ACCOUNT_ID_BYTES = 1024;
const MAX_CREATIVE_ID_BYTES = 1024;
@@ -32,7 +37,77 @@ const activeFrames = new WeakMap();
const pendingFrameCancels = new WeakMap void>();
const RENDERER_READY_MESSAGE = 'trusted-server/aps/renderer-ready';
const RENDERER_FAILED_MESSAGE = 'trusted-server/aps/renderer-failed';
-const RENDERER_READY_TIMEOUT_MS = 10_000;
+/**
+ * How long a renderer frame has to acknowledge before its render is failed.
+ *
+ * Exported because the consumed-ad-ID tombstone has to outlive this window: the
+ * frame starts its timer only after the capability round trip, so a timeout
+ * relay arrives later than this many milliseconds after the ad ID was consumed.
+ */
+export const RENDERER_READY_TIMEOUT_MS = 10_000;
+
+/**
+ * Message the Universal Creative frame relays to the top window when an APS
+ * render never completes.
+ *
+ * The creative frame is cross-origin, so the top-window listener treats every
+ * field as untrusted and resolves the reason through
+ * [`apsRenderFailureReason`] before recording it. The relay is
+ * diagnostics-only and never influences creative delivery.
+ */
+export const APS_RENDER_FAILED_MESSAGE = 'trusted-server/aps/render-failed';
+
+/**
+ * Wire reasons the render path can emit, mapped onto safe diagnostic categories.
+ *
+ * Built on a null prototype so a hostile `__proto__`, `constructor`, or
+ * `toString` relayed by the cross-origin creative frame resolves to `undefined`
+ * rather than an inherited member.
+ */
+let apsRenderFailureReasons: Readonly> | undefined;
+
+/**
+ * Build the reason allowlist on first use.
+ *
+ * Deferred so the table is not a module-level side effect: the Prebid shim
+ * imports two unrelated helpers from this module, and `Object.freeze` at module
+ * scope would defeat tree-shaking and carry this table into that bundle.
+ */
+function renderFailureReasons(): Readonly> {
+ apsRenderFailureReasons ??= Object.freeze(
+ Object.assign(
+ Object.create(null) as Record,
+ {
+ bad_hash: 'aps_bad_hash',
+ nonce_mismatch: 'aps_nonce_mismatch',
+ descriptor_keys: 'aps_descriptor_keys',
+ descriptor_fields: 'aps_descriptor_fields',
+ descriptor_envelope: 'aps_descriptor_envelope',
+ amazon_script_error: 'aps_runner_script_error',
+ frame_timeout: 'aps_frame_timeout',
+ frame_load_error: 'aps_frame_load_error',
+ frame_reported_failure: 'aps_frame_reported_failure',
+ unknown: 'aps_unknown',
+ } as const
+ )
+ );
+ return apsRenderFailureReasons;
+}
+
+/**
+ * Resolve a relayed render failure reason to a safe diagnostic category.
+ *
+ * Returns `undefined` for anything not on the allowlist, so an unrecognized or
+ * hostile value from the cross-origin creative frame is dropped instead of
+ * being recorded.
+ *
+ * @example
+ * apsRenderFailureReason('frame_timeout'); // 'aps_frame_timeout'
+ * apsRenderFailureReason('__proto__'); // undefined
+ */
+export function apsRenderFailureReason(value: unknown): GptDiagnosticsCreativeFailure | undefined {
+ return typeof value === 'string' ? renderFailureReasons()[value] : undefined;
+}
const MAX_PREBID_RENDERER_ENTRIES = 256;
const DEFAULT_PREBID_RENDERER_TTL_SECONDS = 300;
const MAX_PREBID_RENDERER_TTL_SECONDS = 3600;
@@ -672,10 +747,14 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative
if (activeFrames.get(container) === iframe) activeFrames.delete(container);
iframe.remove();
};
- const fail = (): void => {
+ // `reason` is the allowlisted category when the frame named one, so the log
+ // distinguishes a rejected descriptor from a timeout instead of reporting
+ // every cause as a load failure. Recording it against a diagnostics attempt
+ // depends on the attempt-lifecycle work tracked separately.
+ const fail = (reason?: GptDiagnosticsCreativeFailure): void => {
if (settled) return;
cancel();
- log.warn('APS renderer: frame load failed');
+ log.warn(`APS renderer: frame failed (${reason ?? 'unspecified'})`);
};
const commit = (): void => {
if (settled || activeFrames.get(container) !== iframe || !iframe.isConnected) return;
@@ -688,12 +767,22 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative
iframe.style.display = '';
};
function receive(event: MessageEvent): void {
- if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) {
+ if (event.source !== iframe.contentWindow || !isRecord(event.data)) return;
+ const { message, nonce: sent } = event.data;
+ if (message === RENDERER_READY_MESSAGE) {
+ if (hasExactKeys(event.data, ['message', 'nonce']) && sent === nonce) commit();
return;
}
- if (event.data.nonce !== nonce) return;
- if (event.data.message === RENDERER_READY_MESSAGE) commit();
- else if (event.data.message === RENDERER_FAILED_MESSAGE) fail();
+ // A failure message also carries a `reason`. `bad_hash` is the only report
+ // a frame sends before it has learned a nonce, so accepting a nonce-less
+ // failure is narrowed to that reason: anything else running in the frame
+ // could otherwise tear it down without knowing the nonce.
+ if (
+ message === RENDERER_FAILED_MESSAGE &&
+ (sent === nonce || (sent === undefined && event.data.reason === 'bad_hash'))
+ ) {
+ fail(apsRenderFailureReason(event.data.reason));
+ }
}
window.addEventListener('message', receive);
@@ -714,9 +803,9 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative
},
{ once: true }
);
- iframe.addEventListener('error', fail, { once: true });
+ iframe.addEventListener('error', () => fail('aps_frame_load_error'), { once: true });
- const timeoutId = window.setTimeout(fail, RENDERER_READY_TIMEOUT_MS);
+ const timeoutId = window.setTimeout(() => fail('aps_frame_timeout'), RENDERER_READY_TIMEOUT_MS);
pendingFrameCancels.set(container, cancel);
container.appendChild(iframe);
return true;
@@ -725,8 +814,22 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative
/**
* Static source executed by Prebid Universal Creative's dynamic-renderer frame.
* It reads only the validated descriptor and trusted absolute endpoint URL from data.
+ *
+ * Failure reports go out through Universal Creative's own `sendMessage` helper
+ * rather than `postMessage` on the creative window. Universal Creative runs this
+ * source inside a hidden frame it creates in the creative document, so a
+ * `postMessage` call made from here carries that hidden frame as
+ * `event.source`, whatever window it targets: the source is the realm of the
+ * running function, not the window the method is called on. The top-window
+ * listener authenticates a sender by matching it against iframes in the
+ * publisher document, which the hidden frame is not, so such a report is always
+ * dropped. The helper is a creative-realm function, so a report sent through it
+ * arrives as the creative iframe the listener already recognizes.
+ *
+ * The helper stringifies the body and stamps Universal Creative's own `adId`,
+ * both of which the consuming bridge already handles.
*/
-export const APS_UNIVERSAL_CREATIVE_RENDERER = String.raw`(function(){window.render=function(d,_h,w){return new Promise(function(resolve,reject){
+export const APS_UNIVERSAL_CREATIVE_RENDERER = String.raw`(function(){window.render=function(d,h,w){return new Promise(function(resolve,reject){
try{var r=d&&d.apsRenderer,u=d&&d.rendererUrl;if(!r||typeof u!=="string")throw new Error("invalid APS renderer data");
var p=new URL(u);if((p.protocol!=="https:"&&p.protocol!=="http:")||p.username||p.password||p.pathname!=="${APS_RENDERER_PATH}"||p.search||p.hash)throw new Error("invalid APS renderer URL");
var c=w.crypto;if(!c||typeof c.getRandomValues!=="function")throw new Error("APS renderer randomness unavailable");
@@ -734,12 +837,13 @@ var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i,
+ consumedIds: Map,
now: number
): void {
for (const [adId, consumed] of consumedIds) {
@@ -1914,7 +1929,7 @@ function pruneConsumedPrebidApsIds(
}
function hasConsumedPrebidApsIdCapacity(
- consumedIds: Map,
+ consumedIds: Map,
adId: string
): boolean {
if (consumedIds.has(adId) || consumedIds.size < MAX_CONSUMED_PREBID_APS_IDS) return true;
@@ -1924,11 +1939,20 @@ function hasConsumedPrebidApsIdCapacity(
}
function recordConsumedPrebidApsId(
- consumedIds: Map,
+ consumedIds: Map,
adId: string,
- expiresAt: number
+ expiresAt: number,
+ adUnitCode: string
): void {
- consumedIds.set(adId, { expiresAt });
+ // A capability consumed with little TTL left would otherwise lose its
+ // tombstone before the renderer frame's timeout relay arrives, dropping the
+ // reason. The frame starts its timer only after the capability round trip, so
+ // the relay lands later than one timeout after consumption: hold the
+ // tombstone for twice that window rather than exactly it. Bounded by the
+ // frame's own timeout so the two cannot drift apart. This also keeps the ad ID
+ // blocked longer, which is the safer direction for a security tombstone.
+ const minimum = Date.now() + 2 * RENDERER_READY_TIMEOUT_MS;
+ consumedIds.set(adId, { expiresAt: Math.max(expiresAt, minimum), adUnitCode });
}
/**
@@ -1962,7 +1986,7 @@ export function installTsRenderBridge(): void {
// is scoped to the slot, not the bare adId: hb_adid is not unique per bid, so
// keying on it alone would let one slot block a distinct slot's render.
const renderingKeys = new Set();
- const consumedPrebidApsIds = new Map();
+ const consumedPrebidApsIds = new Map();
// One consumed APS ad ID per slot is sufficient: a newer bid replaces the
// slot's old ad ID in `window.tsjs.bids`, so the ownership guard rejects it.
const consumedServerApsBySlot = new Map();
@@ -1978,6 +2002,47 @@ export function installTsRenderBridge(): void {
return;
}
+ // Diagnostics relayed by the APS Universal Creative frame. The creative is
+ // cross-origin, so every field is untrusted: the reason resolves through the
+ // allowlist, and the sender must be a frame inside the ad unit the
+ // capability was served to. Recording only; it never answers the sender.
+ if (data['message'] === APS_RENDER_FAILED_MESSAGE) {
+ const failedAdId = data['adId'];
+ const reason = apsRenderFailureReason(data['reason']);
+ if (typeof failedAdId === 'string' && reason !== undefined) {
+ pruneConsumedPrebidApsIds(consumedPrebidApsIds, Date.now());
+ const tombstone = consumedPrebidApsIds.get(failedAdId);
+ let failedSlotId: string | undefined;
+ if (tombstone) {
+ // The Prebid path tombstones the publisher's ad unit code, which can
+ // be a configured prefix rather than a DOM id. The store keys
+ // associations by Trusted Server slot id, so resolve the slot through
+ // the authenticated frame rather than treating the code as a map key:
+ // that resolver handles exact ids, prefixes, and ambiguity, and
+ // `divToSlotId` holds only resolved ids. The capability's own source
+ // check still gates it, so a sender outside the served ad unit is
+ // rejected before the slot is resolved.
+ if (sourceFrameForAdUnit(e.source, tombstone.adUnitCode)) {
+ failedSlotId = slotFrameForMessageSource(e.source)?.slotId;
+ }
+ } else {
+ // Server-side APS winners are served the same renderer and emit the
+ // same relay, but are tracked per slot. `hb_adid` is not unique per
+ // slot, so require the sender to sit in the slot that consumed it.
+ for (const [slotId, consumedAdId] of consumedServerApsBySlot) {
+ if (consumedAdId === failedAdId && sourceFrameForSlotId(e.source, slotId)) {
+ failedSlotId = slotId;
+ break;
+ }
+ }
+ }
+ if (failedSlotId !== undefined) {
+ safelyRecordCreativeFailure(safelyRecordCreativeRequest(failedSlotId), reason);
+ }
+ }
+ return;
+ }
+
if (data['message'] !== 'Prebid Request') return;
const adId = data['adId'] as string | undefined;
if (!adId) return;
@@ -2008,7 +2073,12 @@ export function installTsRenderBridge(): void {
const renderer = validateApsRenderer(prebidRendererEntry.renderer);
if (!renderer || !hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return;
if (!consumeApsPrebidRenderer(adId, prebidRendererEntry)) return;
- recordConsumedPrebidApsId(consumedPrebidApsIds, adId, prebidRendererEntry.expiresAt);
+ recordConsumedPrebidApsId(
+ consumedPrebidApsIds,
+ adId,
+ prebidRendererEntry.expiresAt,
+ prebidRendererEntry.adUnitCode
+ );
const markUsed = (): void => {
try {
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts
index 63c0b6fb3e..b9231ab8d3 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts
@@ -184,6 +184,26 @@ function creativeFailureFact(
return 'Creative bridge failure: invalid cache payload';
case 'response_post_failed':
return 'Creative bridge failure: response post failed';
+ case 'aps_bad_hash':
+ return 'APS render failure: renderer frame received no nonce';
+ case 'aps_nonce_mismatch':
+ return 'APS render failure: renderer nonce mismatch';
+ case 'aps_descriptor_keys':
+ return 'APS render failure: descriptor key set rejected';
+ case 'aps_descriptor_fields':
+ return 'APS render failure: descriptor fields rejected';
+ case 'aps_descriptor_envelope':
+ return 'APS render failure: descriptor envelope rejected';
+ case 'aps_runner_script_error':
+ return 'APS render failure: Amazon runner script failed to load';
+ case 'aps_frame_timeout':
+ return 'APS render failure: renderer frame never acknowledged';
+ case 'aps_frame_load_error':
+ return 'APS render failure: renderer frame raised a load error (rare; an HTTP error on the renderer route reports a timeout instead)';
+ case 'aps_frame_reported_failure':
+ return 'APS render failure: renderer frame reported an unlabelled failure';
+ case 'aps_unknown':
+ return 'APS render failure: unknown';
}
}
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts
index ca3348ae97..f6a73426d3 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts
@@ -16,6 +16,7 @@ import type {
GptDiagnosticsTrustedServerOpportunity,
Size,
} from '../../core/types';
+import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../core/types';
export const MAX_DIAGNOSTIC_SLOTS = 64;
export const MAX_REQUEST_CYCLES_PER_SLOT = 10;
@@ -165,13 +166,10 @@ function isTrustedServerOpportunity(
);
}
+const CREATIVE_FAILURES: ReadonlySet = new Set(GPT_DIAGNOSTICS_CREATIVE_FAILURES);
+
function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure {
- return (
- reason === 'missing_render_source' ||
- reason === 'cache_fetch_failed' ||
- reason === 'invalid_cache_payload' ||
- reason === 'response_post_failed'
- );
+ return typeof reason === 'string' && CREATIVE_FAILURES.has(reason);
}
function validDuration(start: number | undefined, end: number | undefined): number | undefined {
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 a5a229f5e0..904b97d489 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
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import envelope from '../../fixtures/aps-renderer-v1.json';
import type { ApsRendererV1 } from '../../../src/core/types';
+import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../../src/core/types';
import { log } from '../../../src/core/log';
import {
APS_NATIVE_RENDERER_TIMEOUT_MS,
@@ -9,8 +10,10 @@ import {
APS_RENDERER_PATH,
APS_RENDERER_SANDBOX,
APS_RENDERING_MODE_ATTRIBUTE_NAME,
+ APS_RENDER_FAILED_MESSAGE,
APS_UNIVERSAL_CREATIVE_RENDERER,
APS_UNIVERSAL_CREATIVE_RENDERER_VERSION,
+ apsRenderFailureReason,
apsRendererUrl,
dispatchApsRendering as dispatchDefaultApsRendering,
getApsPrebidRenderer,
@@ -724,6 +727,87 @@ describe('direct APS rendering', () => {
expect(document.querySelector('#fictional-slot iframe')).toBeNull();
});
+ it('tears down the frame when the renderer document reports a failure reason', () => {
+ expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
+ const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement;
+ const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage');
+ iframe.dispatchEvent(new Event('load'));
+ const sent = postMessage.mock.calls[0][0] as { nonce: string };
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ message: 'trusted-server/aps/renderer-failed',
+ nonce: sent.nonce,
+ reason: 'descriptor_envelope',
+ },
+ source: iframe.contentWindow,
+ })
+ );
+
+ expect(document.querySelector('#fictional-slot iframe')).toBeNull();
+ expect(document.querySelector('#fictional-slot span')).not.toBeNull();
+ });
+
+ it('tears down the frame on a bad-hash report that carries no nonce to echo', () => {
+ expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
+ const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement;
+ iframe.dispatchEvent(new Event('load'));
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ message: 'trusted-server/aps/renderer-failed',
+ nonce: undefined,
+ reason: 'bad_hash',
+ },
+ source: iframe.contentWindow,
+ })
+ );
+
+ expect(document.querySelector('#fictional-slot iframe')).toBeNull();
+ });
+
+ it('ignores a failure report bearing a foreign nonce', () => {
+ expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
+ const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement;
+ iframe.dispatchEvent(new Event('load'));
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ message: 'trusted-server/aps/renderer-failed',
+ nonce: 'a-foreign-nonce',
+ reason: 'bad_hash',
+ },
+ source: iframe.contentWindow,
+ })
+ );
+
+ expect(document.querySelector('#fictional-slot iframe')).not.toBeNull();
+ });
+
+ it('still rejects a ready message carrying an unexpected extra key', () => {
+ expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
+ const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement;
+ const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage');
+ iframe.dispatchEvent(new Event('load'));
+ const sent = postMessage.mock.calls[0][0] as { nonce: string };
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ message: 'trusted-server/aps/renderer-ready',
+ nonce: sent.nonce,
+ reason: 'unexpected',
+ },
+ source: iframe.contentWindow,
+ })
+ );
+
+ expect(iframe.style.display).toBe('none');
+ });
+
it('removes an unacknowledged frame without clearing publisher content', () => {
vi.useFakeTimers();
try {
@@ -855,3 +939,213 @@ describe('Universal Creative APS source', () => {
}
});
});
+
+describe('APS render failure reason allowlist', () => {
+ it('maps every reason the renderer frame and creative source can emit', () => {
+ expect(apsRenderFailureReason('bad_hash')).toBe('aps_bad_hash');
+ expect(apsRenderFailureReason('nonce_mismatch')).toBe('aps_nonce_mismatch');
+ expect(apsRenderFailureReason('descriptor_keys')).toBe('aps_descriptor_keys');
+ expect(apsRenderFailureReason('descriptor_fields')).toBe('aps_descriptor_fields');
+ expect(apsRenderFailureReason('descriptor_envelope')).toBe('aps_descriptor_envelope');
+ expect(apsRenderFailureReason('amazon_script_error')).toBe('aps_runner_script_error');
+ expect(apsRenderFailureReason('frame_timeout')).toBe('aps_frame_timeout');
+ expect(apsRenderFailureReason('frame_load_error')).toBe('aps_frame_load_error');
+ expect(apsRenderFailureReason('frame_reported_failure')).toBe('aps_frame_reported_failure');
+ expect(apsRenderFailureReason('unknown')).toBe('aps_unknown');
+ });
+
+ it('resolves every mapped reason to a category the diagnostics store accepts', () => {
+ for (const wire of [
+ 'bad_hash',
+ 'nonce_mismatch',
+ 'descriptor_keys',
+ 'descriptor_fields',
+ 'descriptor_envelope',
+ 'amazon_script_error',
+ 'frame_timeout',
+ 'frame_load_error',
+ 'frame_reported_failure',
+ 'unknown',
+ ]) {
+ const reason = apsRenderFailureReason(wire);
+ expect(GPT_DIAGNOSTICS_CREATIVE_FAILURES).toContain(reason);
+ }
+ });
+
+ it('rejects unlisted, inherited, and non-string reasons from the cross-origin frame', () => {
+ expect(apsRenderFailureReason('not_a_real_reason')).toBeUndefined();
+ expect(apsRenderFailureReason('__proto__')).toBeUndefined();
+ expect(apsRenderFailureReason('constructor')).toBeUndefined();
+ expect(apsRenderFailureReason('toString')).toBeUndefined();
+ expect(apsRenderFailureReason('hasOwnProperty')).toBeUndefined();
+ expect(apsRenderFailureReason(42)).toBeUndefined();
+ expect(apsRenderFailureReason(undefined)).toBeUndefined();
+ expect(apsRenderFailureReason(null)).toBeUndefined();
+ expect(apsRenderFailureReason({ toString: () => 'frame_timeout' })).toBeUndefined();
+ });
+});
+
+describe('Universal Creative APS failure relay', () => {
+ /**
+ * Stand-in for the `sendMessage` helper Universal Creative passes to
+ * `render`. It mirrors the real one: the body is JSON, the helper stamps its
+ * own `adId`, and the payload is applied last so a caller can override both.
+ */
+ function pucHelper(adId: string): { sendMessage: (type: string, payload: object) => void } {
+ return {
+ sendMessage: (type, payload) =>
+ window.postMessage(JSON.stringify({ message: type, adId, ...payload }), '*'),
+ };
+ }
+
+ function captureRelays(): {
+ relayed: Array>;
+ stop: () => void;
+ } {
+ const relayed: Array> = [];
+ const capture = (event: MessageEvent): void => {
+ const data =
+ typeof event.data === 'string'
+ ? (JSON.parse(event.data) as Record)
+ : (event.data as Record);
+ if (data && data['message'] === APS_RENDER_FAILED_MESSAGE) relayed.push(data);
+ };
+ window.addEventListener('message', capture);
+ return { relayed, stop: () => window.removeEventListener('message', capture) };
+ }
+
+ it('relays the frame failure reason through the creative window helper', async () => {
+ const dynamicWindow = window as unknown as {
+ render?: (data: Record, helper: unknown, target: Window) => Promise;
+ };
+ const { relayed, stop } = captureRelays();
+ window.eval(APS_UNIVERSAL_CREATIVE_RENDERER);
+
+ try {
+ const rendered = dynamicWindow.render!(
+ { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() },
+ pucHelper('example-ad-id'),
+ window
+ );
+ const iframe = document.body.querySelector('iframe')!;
+ const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage');
+ iframe.dispatchEvent(new Event('load'));
+ const sent = postMessage.mock.calls[0][0] as { nonce: string };
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ message: 'trusted-server/aps/renderer-failed',
+ nonce: sent.nonce,
+ reason: 'descriptor_envelope',
+ },
+ source: iframe.contentWindow,
+ })
+ );
+
+ await expect(rendered).rejects.toThrow();
+ // `postMessage` is delivered on a later task than the rejection microtask.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(relayed).toEqual([
+ {
+ message: APS_RENDER_FAILED_MESSAGE,
+ adId: 'example-ad-id',
+ reason: 'descriptor_envelope',
+ },
+ ]);
+ } finally {
+ stop();
+ delete dynamicWindow.render;
+ document.body.innerHTML = '';
+ }
+ });
+
+ it('reports a frame timeout when the renderer frame never acknowledges', async () => {
+ const dynamicWindow = window as unknown as {
+ render?: (data: Record, helper: unknown, target: Window) => Promise;
+ };
+ const { relayed, stop } = captureRelays();
+ vi.useFakeTimers();
+ window.eval(APS_UNIVERSAL_CREATIVE_RENDERER);
+
+ try {
+ const rendered = dynamicWindow.render!(
+ { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() },
+ pucHelper('timeout-ad-id'),
+ window
+ );
+ rendered.catch(() => {});
+ document.body.querySelector('iframe')!.dispatchEvent(new Event('load'));
+
+ // Async advance so the queued `postMessage` dispatch task also runs.
+ await vi.advanceTimersByTimeAsync(10_001);
+
+ expect(relayed).toEqual([
+ { message: APS_RENDER_FAILED_MESSAGE, adId: 'timeout-ad-id', reason: 'frame_timeout' },
+ ]);
+ } finally {
+ vi.useRealTimers();
+ stop();
+ delete dynamicWindow.render;
+ document.body.innerHTML = '';
+ }
+ });
+
+ it('labels a frame that fails to load', async () => {
+ const dynamicWindow = window as unknown as {
+ render?: (data: Record, helper: unknown, target: Window) => Promise;
+ };
+ const { relayed, stop } = captureRelays();
+ window.eval(APS_UNIVERSAL_CREATIVE_RENDERER);
+
+ try {
+ const rendered = dynamicWindow.render!(
+ { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() },
+ pucHelper('load-error-ad-id'),
+ window
+ );
+ rendered.catch(() => {});
+ const iframe = document.body.querySelector('iframe') as HTMLIFrameElement & {
+ onerror?: () => void;
+ };
+ iframe.onerror?.();
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(relayed.map((entry) => entry['reason'])).toEqual(['frame_load_error']);
+ } finally {
+ stop();
+ delete dynamicWindow.render;
+ document.body.innerHTML = '';
+ }
+ });
+
+ it('stays silent when Universal Creative provides no message helper', async () => {
+ const dynamicWindow = window as unknown as {
+ render?: (data: Record, helper: unknown, target: Window) => Promise;
+ };
+ const { relayed, stop } = captureRelays();
+ window.eval(APS_UNIVERSAL_CREATIVE_RENDERER);
+
+ try {
+ const rendered = dynamicWindow.render!(
+ { apsRenderer: descriptor(), rendererUrl: apsRendererUrl() },
+ undefined,
+ window
+ );
+ rendered.catch(() => {});
+ const iframe = document.body.querySelector('iframe') as HTMLIFrameElement & {
+ onerror?: () => void;
+ };
+ iframe.onerror?.();
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ // No helper means no relay, and the render still rejects rather than hanging.
+ expect(relayed).toEqual([]);
+ await expect(rendered).rejects.toThrow();
+ } finally {
+ stop();
+ delete dynamicWindow.render;
+ document.body.innerHTML = '';
+ }
+ });
+});
diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts
index 540d15229a..b757b51deb 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
@@ -13,7 +13,11 @@ import type { AuctionBidData, TsjsApi } from '../../../src/core/types';
import {
APS_PREBID_CREATIVE_RUNNER_URL,
APS_RENDERING_MODE_ATTRIBUTE_NAME,
+ APS_RENDER_FAILED_MESSAGE,
+ APS_UNIVERSAL_CREATIVE_RENDERER_VERSION,
+ RENDERER_READY_TIMEOUT_MS,
} from '../../../src/integrations/aps/render';
+import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store';
let publisherNativeScript: HTMLScriptElement | undefined;
@@ -3486,7 +3490,7 @@ describe('installTsRenderBridge', () => {
message: 'Prebid Response',
adId: renderer.bidId,
renderer: expect.stringContaining('window.render=function'),
- rendererVersion: 4,
+ rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION,
rendererUrl: new URL('/integrations/aps/renderer', window.location.origin).href,
apsRenderer: renderer,
width: 300,
@@ -3663,6 +3667,339 @@ describe('installTsRenderBridge', () => {
foreignIframe.remove();
});
+ it('records a relayed APS render failure against a real diagnostics store', async () => {
+ const renderer = apsRenderer();
+ const prebidAdId = 'prebid-relay-ad-id';
+ const store = new GptDiagnosticsStore({ defer: () => undefined });
+ (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never;
+ (window as TestWindow).tsjs.apsPrebidRenderers = {
+ [prebidAdId]: {
+ adUnitCode: 'div-header',
+ renderer,
+ registeredAt: Date.now(),
+ expiresAt: Date.now() + 60_000,
+ markUsed: vi.fn(),
+ },
+ };
+
+ try {
+ const bridgeListener = await captureBridgeListener();
+ const source = createTrustedSlotIframe();
+ const slot = {
+ getSlotElementId: () => 'div-header',
+ getAdUnitPath: () => '/example/div-header',
+ };
+ // Production associates under the configured slot id and maps the div to
+ // it, so the ids differ here the way they do on a real page.
+ (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' };
+ store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate');
+ store.recordSlotRequested(slot);
+
+ // Serve the capability so the ad ID is tombstoned with its ad unit.
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }),
+ ports: [{ postMessage: () => {} }],
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ // The creative frame then relays a render failure to the top window.
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: {
+ message: APS_RENDER_FAILED_MESSAGE,
+ adId: prebidAdId,
+ reason: 'frame_timeout',
+ },
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([
+ 'aps_frame_timeout',
+ ]);
+ } finally {
+ delete (window as TestWindow).tsjs.gptDiagnosticsRecorder;
+ }
+ });
+
+ it('records a frame timeout relayed after a short-TTL capability would have expired', async () => {
+ const renderer = apsRenderer();
+ const prebidAdId = 'prebid-short-ttl-ad-id';
+ const store = new GptDiagnosticsStore({ defer: () => undefined });
+ (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never;
+ // The renderer entry expires almost immediately, so only the tombstone's
+ // own minimum keeps the ad ID known long enough to attribute the relay.
+ (window as TestWindow).tsjs.apsPrebidRenderers = {
+ [prebidAdId]: {
+ adUnitCode: 'div-header',
+ renderer,
+ registeredAt: Date.now(),
+ expiresAt: Date.now() + 1_000,
+ markUsed: vi.fn(),
+ },
+ };
+
+ const nowSpy = vi.spyOn(Date, 'now');
+ try {
+ const bridgeListener = await captureBridgeListener();
+ const source = createTrustedSlotIframe();
+ const slot = {
+ getSlotElementId: () => 'div-header',
+ getAdUnitPath: () => '/example/div-header',
+ };
+ (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' };
+ store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate');
+ store.recordSlotRequested(slot);
+
+ const consumedAt = Date.now();
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }),
+ ports: [{ postMessage: () => {} }],
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ // The frame starts its own timer only after the capability round trip, so
+ // its timeout relay lands after consumption plus the frame timeout.
+ nowSpy.mockReturnValue(consumedAt + RENDERER_READY_TIMEOUT_MS + 50);
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: {
+ message: APS_RENDER_FAILED_MESSAGE,
+ adId: prebidAdId,
+ reason: 'frame_timeout',
+ },
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([
+ 'aps_frame_timeout',
+ ]);
+ } finally {
+ nowSpy.mockRestore();
+ delete (window as TestWindow).tsjs.gptDiagnosticsRecorder;
+ }
+ });
+
+ it('records a relayed APS render failure for a prefix-based ad unit', async () => {
+ const renderer = apsRenderer();
+ const prebidAdId = 'prebid-prefix-relay-ad-id';
+ const store = new GptDiagnosticsStore({ defer: () => undefined });
+ (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never;
+ (window as TestWindow).tsjs.apsPrebidRenderers = {
+ [prebidAdId]: {
+ // A configured prefix rather than a DOM id, which `divToSlotId` never
+ // holds as a key: the slot has to come from the authenticated frame.
+ adUnitCode: 'div-',
+ renderer,
+ registeredAt: Date.now(),
+ expiresAt: Date.now() + 60_000,
+ markUsed: vi.fn(),
+ },
+ };
+
+ try {
+ const bridgeListener = await captureBridgeListener();
+ const source = createTrustedSlotIframe();
+ const slot = {
+ getSlotElementId: () => 'div-header',
+ getAdUnitPath: () => '/example/div-header',
+ };
+ (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' };
+ store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate');
+ store.recordSlotRequested(slot);
+
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }),
+ ports: [{ postMessage: () => {} }],
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: {
+ message: APS_RENDER_FAILED_MESSAGE,
+ adId: prebidAdId,
+ reason: 'frame_timeout',
+ },
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([
+ 'aps_frame_timeout',
+ ]);
+ } finally {
+ delete (window as TestWindow).tsjs.gptDiagnosticsRecorder;
+ }
+ });
+
+ it('records a relayed APS render failure for a server-side APS winner', async () => {
+ const renderer = apsRenderer();
+ const store = new GptDiagnosticsStore({ defer: () => undefined });
+ (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never;
+ (window as TestWindow).tsjs.bids.homepage_header = {
+ hb_adid: renderer.bidId,
+ hb_bidder: 'aps',
+ hb_pb: '1.23',
+ renderer,
+ };
+
+ try {
+ const bridgeListener = await captureBridgeListener();
+ const source = createTrustedSlotIframe();
+ const slot = {
+ getSlotElementId: () => 'div-header',
+ getAdUnitPath: () => '/example/div-header',
+ };
+ (window as TestWindow).tsjs.divToSlotId = { 'div-header': 'homepage_header' };
+ store.recordTrustedServerOpportunity(slot, 'homepage_header', 'renderable_candidate');
+ store.recordSlotRequested(slot);
+
+ // Serve the server-side capability, which is tracked per slot rather than
+ // through the Prebid renderer tombstone.
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }),
+ ports: [{ postMessage: () => {} }],
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: {
+ message: APS_RENDER_FAILED_MESSAGE,
+ adId: renderer.bidId,
+ reason: 'amazon_script_error',
+ },
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([
+ 'aps_runner_script_error',
+ ]);
+ } finally {
+ delete (window as TestWindow).tsjs.gptDiagnosticsRecorder;
+ }
+ });
+
+ it('drops a relayed APS failure from a frame outside the served ad unit', async () => {
+ const renderer = apsRenderer();
+ const prebidAdId = 'prebid-foreign-relay-ad-id';
+ const recordTrustedServerCreativeFailure = vi.fn();
+ (window as TestWindow).tsjs.gptDiagnosticsRecorder = {
+ recordTrustedServerOpportunity: vi.fn(),
+ recordTrustedServerCreativeRequest: vi.fn(() => 3),
+ recordTrustedServerCreativeResponse: vi.fn(),
+ recordTrustedServerCreativeFailure,
+ } as never;
+ (window as TestWindow).tsjs.apsPrebidRenderers = {
+ [prebidAdId]: {
+ adUnitCode: 'div-header',
+ renderer,
+ registeredAt: Date.now(),
+ expiresAt: Date.now() + 60_000,
+ markUsed: vi.fn(),
+ },
+ };
+
+ try {
+ const bridgeListener = await captureBridgeListener();
+ const source = createTrustedSlotIframe();
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }),
+ ports: [{ postMessage: () => {} }],
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+ recordTrustedServerCreativeFailure.mockClear();
+
+ // A frame that is not inside the served ad unit cannot forge a failure,
+ // even though it names a live ad ID and an allowlisted reason.
+ const foreignIframe = document.createElement('iframe');
+ document.body.appendChild(foreignIframe);
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: { message: APS_RENDER_FAILED_MESSAGE, adId: prebidAdId, reason: 'frame_timeout' },
+ source: foreignIframe.contentWindow,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+
+ expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled();
+ foreignIframe.remove();
+ } finally {
+ delete (window as TestWindow).tsjs.gptDiagnosticsRecorder;
+ }
+ });
+
+ it('drops a relayed APS failure whose reason is not on the allowlist', async () => {
+ const renderer = apsRenderer();
+ const prebidAdId = 'prebid-hostile-reason-ad-id';
+ const recordTrustedServerCreativeFailure = vi.fn();
+ (window as TestWindow).tsjs.gptDiagnosticsRecorder = {
+ recordTrustedServerOpportunity: vi.fn(),
+ recordTrustedServerCreativeRequest: vi.fn(() => 4),
+ recordTrustedServerCreativeResponse: vi.fn(),
+ recordTrustedServerCreativeFailure,
+ } as never;
+ (window as TestWindow).tsjs.apsPrebidRenderers = {
+ [prebidAdId]: {
+ adUnitCode: 'div-header',
+ renderer,
+ registeredAt: Date.now(),
+ expiresAt: Date.now() + 60_000,
+ markUsed: vi.fn(),
+ },
+ };
+
+ try {
+ const bridgeListener = await captureBridgeListener();
+ const source = createTrustedSlotIframe();
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }),
+ ports: [{ postMessage: () => {} }],
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+ recordTrustedServerCreativeFailure.mockClear();
+
+ for (const reason of ['__proto__', 'constructor', 'not_a_reason', 7]) {
+ bridgeListener(
+ Object.assign(new Event('message'), {
+ data: { message: APS_RENDER_FAILED_MESSAGE, adId: prebidAdId, reason },
+ source,
+ stopImmediatePropagation: vi.fn(),
+ }) as unknown as MessageEvent
+ );
+ }
+
+ expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled();
+ } finally {
+ delete (window as TestWindow).tsjs.gptDiagnosticsRecorder;
+ }
+ });
+
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';
diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts
index 52aef6a7f5..1701ba8012 100644
--- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts
+++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts
@@ -14,6 +14,7 @@ import {
TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS,
type GptDiagnosticsSlotLike,
} from '../../../src/integrations/gpt_diagnostics/store';
+import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../../src/core/types';
function fakeSlot(elementId: string, adUnitPath = `/example/${elementId}`): GptDiagnosticsSlotLike {
return {
@@ -1420,6 +1421,27 @@ describe('GptDiagnosticsStore', () => {
expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent');
});
+ // The runtime allowlist and the exported union are derived from one array, so
+ // a member that exists at compile time must also be recordable at runtime. A
+ // hand-written allowlist previously drifted and dropped reasons silently.
+ it('records every declared creative failure category', () => {
+ for (const reason of GPT_DIAGNOSTICS_CREATIVE_FAILURES) {
+ const store = new GptDiagnosticsStore({ now: () => 0, defer: () => undefined });
+ const slot = fakeSlot(`declared-${reason}`);
+ associateSlot(store, slot, `auction-${reason}`);
+ store.recordSlotRequested(slot);
+ const attemptId = store.recordTrustedServerCreativeRequest(`auction-${reason}`)!;
+
+ store.recordTrustedServerCreativeFailure(attemptId, reason);
+
+ expect(
+ store.snapshot().slots[0].requests[0].trustedServerCreativeFailures,
+ `should record the \`${reason}\` category`
+ ).toEqual([reason]);
+ expect(store.snapshot().attributionIssues).toEqual([]);
+ }
+ });
+
it('keeps an asynchronous response on its originating cycle after a newer refresh', () => {
let now = 0;
const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined });
diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md
index af6f2e3248..7e0e104c10 100644
--- a/docs/guide/integrations/gpt-diagnostics.md
+++ b/docs/guide/integrations/gpt-diagnostics.md
@@ -234,6 +234,32 @@ Failures are deduplicated and retain first-observed order. Detailed URLs, cache
payloads, markup, and error objects remain only in existing operational logging and do
not enter diagnostics.
+### APS render failures
+
+An APS bid that wins targeting is served as a 1x1 universal creative that resizes
+itself only once the creative draws, so Ad Manager reports a non-empty render whether
+or not anything was drawn. These categories name the guard that stopped the render, so
+a blank slot is distinguishable from a delivered one.
+
+They are relayed diagnostics rather than bridge failures: the sandboxed renderer
+document or the Universal Creative source observes the failure and reports it, the
+reason is validated against a fixed allowlist, and the sender is authenticated against
+the slot the capability was served to. A reason never carries creative or descriptor
+content.
+
+| Failure | Observed by | Meaning |
+| ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `aps_bad_hash` | Renderer document | The frame loaded without a usable nonce fragment, so no descriptor was accepted. |
+| `aps_nonce_mismatch` | Renderer document | A descriptor arrived bearing a nonce other than the one the frame was opened with. |
+| `aps_descriptor_keys` | Renderer document | The descriptor's key set did not match either accepted shape. |
+| `aps_descriptor_fields` | Renderer document | A descriptor field was absent, the wrong type, or over its size limit. |
+| `aps_descriptor_envelope` | Renderer document | The encoded bid envelope failed to decode or disagreed with the descriptor. |
+| `aps_runner_script_error` | Renderer document | Amazon's `prebid-creative.js` failed to load, so the creative was never rendered. |
+| `aps_frame_timeout` | Universal Creative source | The renderer frame never acknowledged within its timeout. A missing or erroring renderer route also surfaces here, because an iframe does not raise a load error for an HTTP status. |
+| `aps_frame_load_error` | Universal Creative source | The renderer frame raised a load error. Rare; see the timeout row. |
+| `aps_frame_reported_failure` | Universal Creative source | The frame reported a failure without naming a category. |
+| `aps_unknown` | Universal Creative source | The frame reported an empty reason. A reason the top window does not recognize, which can happen when the renderer document and the script bundle are on different versions, is dropped rather than recorded. |
+
### Source-neutral GAM facts
`slotRenderEnded` may expose line item, order, advertiser, creative, yield-group, and