Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b56a0a1
Add design spec for SSAT inline creative rendering
prk-Jr Jul 13, 2026
6e8c48f
Add implementation plan for SSAT inline creative rendering
prk-Jr Jul 13, 2026
16e9d95
Correct plan call graph, add TsjsApi type + beacon steps, note precon…
prk-Jr Jul 13, 2026
34018e4
Revise SSAT inline-creative plan/spec per review
prk-Jr Jul 13, 2026
a1b844d
Refine plan test details per second review
prk-Jr Jul 13, 2026
6649875
Always include adm in bid map; gate only debug_bid blob
prk-Jr Jul 13, 2026
e2b13bb
Gate GAM-bypass adm injection on per-bid debug_bid
prk-Jr Jul 13, 2026
cb60fd2
Apply prettier formatting to spec/plan docs
prk-Jr Jul 13, 2026
1159407
Merge branch 'main' into feat/ssat-render-inline-creative
prk-Jr Jul 15, 2026
1c98302
Run SSAT inline creative through the sanitize/rewrite boundary and de…
prk-Jr Jul 16, 2026
d0b18b4
Emit absolute first-party URLs for inline SSAT creatives
prk-Jr Jul 17, 2026
76dd06b
Merge branch 'main' into feat/ssat-render-inline-creative
aram356 Jul 19, 2026
bad87ae
Merge branch 'main' into feat/ssat-render-inline-creative
prk-Jr Jul 21, 2026
b2e736e
Resolve pbRender bid by requesting slot, not first hb_adid match
prk-Jr Jul 21, 2026
d97ac1d
Size inline creative render from the winning bid dimensions
prk-Jr Jul 21, 2026
f0f4a7f
Expand ${AUCTION_PRICE} before rewriting inline creatives
prk-Jr Jul 21, 2026
d0e00a6
Build inline creative URLs from the request origin
prk-Jr Jul 21, 2026
3a7e7e2
Preserve cached bid dimensions and price in the PBS Cache fallback
prk-Jr Jul 21, 2026
395abd6
Reconcile SSAT inline-creative design and plan with implementation
prk-Jr Jul 21, 2026
ebd23b2
Allow too_many_arguments on collect_stream_auction
prk-Jr Jul 21, 2026
44c2bb8
Omit zero creative dimensions so the bridge falls back
prk-Jr Jul 21, 2026
f4e0a79
Scope the pbRender in-flight guard to the slot, not the bare adId
prk-Jr Jul 21, 2026
d434943
Merge branch 'main' into feat/ssat-render-inline-creative
prk-Jr Jul 22, 2026
2ad899f
Expand ${AUCTION_PRICE} in win and billing notification URLs
prk-Jr Jul 22, 2026
07890a9
Suppress fabricated empty Prebid bidder params (#910)
prk-Jr Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 260 additions & 35 deletions crates/trusted-server-core/src/creative.rs

Large diffs are not rendered by default.

429 changes: 405 additions & 24 deletions crates/trusted-server-core/src/integrations/prebid.rs

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions crates/trusted-server-core/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2730,7 +2730,7 @@ mod tests {
let settings = create_test_settings();
let clear = "https://cdn.example/asset.js?c=3&b=2&a=1";
// Simulate creative-generated first-party URL
let first_party = creative::build_proxy_url(&settings, clear);
let first_party = creative::build_proxy_url(&settings, clear, "");
// Reconstruct and validate (need absolute URL for parsing)
let st = reconstruct_and_validate_signed_target(
&settings,
Expand All @@ -2746,7 +2746,7 @@ mod tests {
fn reconstruct_valid_without_params() {
let settings = create_test_settings();
let clear = "https://cdn.example/asset.js";
let first_party = creative::build_proxy_url(&settings, clear);
let first_party = creative::build_proxy_url(&settings, clear, "");
let st = reconstruct_and_validate_signed_target(
&settings,
&format!("https://edge.example{}", first_party),
Expand All @@ -2763,7 +2763,7 @@ mod tests {
let settings = create_test_settings();
let clear = "ftp://cdn.example/file.gif";
// Build a first-party proxy URL with a token for the unsupported scheme
let first_party = creative::build_proxy_url(&settings, clear);
let first_party = creative::build_proxy_url(&settings, clear, "");
let req =
build_http_request(Method::GET, format!("https://edge.example{}", first_party));
let err: Report<TrustedServerError> =
Expand Down Expand Up @@ -2802,7 +2802,7 @@ mod tests {
futures::executor::block_on(async {
let settings = create_test_settings();
let clear = "https://cdn.example/landing.html?x=1";
let first_party = creative::build_click_url(&settings, clear);
let first_party = creative::build_click_url(&settings, clear, "");
let req =
build_http_request(Method::GET, format!("https://edge.example{}", first_party));
let resp = handle_first_party_click(&settings, &noop_services(), req)
Expand Down
653 changes: 580 additions & 73 deletions crates/trusted-server-core/src/publisher.rs

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions crates/trusted-server-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1913,12 +1913,17 @@ pub struct DebugConfig {
#[serde(default)]
pub auction_html_comment: bool,

/// Include raw `adm` creative markup in `window.tsjs.bids` for GPT/GAM
/// debug rendering through the Prebid Universal Creative bridge.
/// Enable the testing-only direct GAM-replace path and the verbose per-bid
/// `debug_bid` blob in `window.tsjs.bids`.
///
/// Use this to validate the server-side auction→GAM targeting→creative
/// rendering pipeline while PBS Cache is unavailable. Never enable in
/// production — injects raw HTML from SSPs.
/// Note: the sanitized winning `adm` is now injected **unconditionally** for
/// production inline rendering through the pbRender bridge (see
/// [`crate::publisher::build_bid_map`]); this flag no longer gates `adm`.
/// What it still gates is the client-side `debug_bid` signal that turns on
/// the direct GAM-creative replacement (`injectAdmIntoSlot`), which bypasses
/// GAM entirely — useful for validating the auction→creative pipeline while
/// PBS Cache is unavailable. The `debug_bid` blob also carries the raw,
/// un-sanitized creative for diagnostics, so never enable in production.
#[serde(default)]
pub inject_adm_for_testing: bool,
}
Expand Down
20 changes: 18 additions & 2 deletions crates/trusted-server-js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,27 @@ export interface AuctionBidData {
hb_adid?: string;
hb_cache_host?: string;
hb_cache_path?: string;
/** Winning creative width; the bridge sizes the inline render from this. */
w?: number;
/** Winning creative height; the bridge sizes the inline render from this. */
h?: number;
nurl?: string;
burl?: string;
/** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */
/**
* Sanitized winning creative markup for local rendering through the pbRender
* bridge. Present whenever the winning bid carried a creative that passed the
* server-side sanitize/rewrite boundary; absent when there was no creative or
* it was rejected (e.g. over the 1 MiB cap), in which case the bridge falls
* back to the PBS Cache coordinates. This is NOT gated by
* `inject_adm_for_testing`.
*/
adm?: string;
/** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */
/**
* Verbose per-bid debug blob (carries the raw, un-sanitized creative among
* other fields). Only present when `[debug] inject_adm_for_testing = true`;
* its presence is also the client-side gate for the testing-only direct
* GAM-replace path.
*/
debug_bid?: AuctionDebugBidData;
}

Expand Down
183 changes: 144 additions & 39 deletions crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,11 @@ export function safeAdmIframeSrc(src: string): string | undefined {
*
* Adapted from PR #241 (github.com/IABTechLab/trusted-server/pull/241).
* Instead of reading from pbjs, reads adm directly from window.tsjs.bids.
* Only active when inject_adm_for_testing injects adm server-side.
*
* This is the testing-only direct-replace path that bypasses GAM entirely. The
* sanitized `adm` now ships in production for the pbRender bridge, so `adm`
* presence no longer gates it; the caller gates on the per-bid `debug_bid`
* signal (present only under `inject_adm_for_testing`) instead.
*
* Strategy:
* 1. If adm contains an <iframe src="..."> with a safe http(s) src, set that
Expand Down Expand Up @@ -593,10 +597,13 @@ export function installTsAdInit(): void {
// Read ts.bids live (not the snapshot above) so post-navigation bid data is used.
const bid = (ts.bids ?? {})[slotId] ?? {};

// GAM interceptor (testing): when adm is present, replace the GAM creative.
// Adapted from PR #241 — uses window.tsjs.bids[slotId].adm instead of pbjs.
// Only active when inject_adm_for_testing injects adm into bids server-side.
if (bid.adm) {
// GAM interceptor (testing bypass): directly replace the GAM creative.
// `adm` is now always injected in production, so it can no longer gate
// this path. `debug_bid` is present only when inject_adm_for_testing is
// on, so it is the per-bid signal that the testing bypass is enabled.
// In production the render bridge serves the creative and GAM stays in
// the loop; this direct replace stays testing-only.
if (bid.adm && bid.debug_bid) {
Comment thread
prk-Jr marked this conversation as resolved.
injectAdmIntoSlot(divId, bid.adm);
}
});
Expand Down Expand Up @@ -819,6 +826,79 @@ const TS_DISPLAY_RENDERER =
'if(d.adUrl&&!d.ad){f.src=d.adUrl;}else{f.srcdoc=d.ad;}' +
'w.document.body.appendChild(f);};})();';

/** The clear-price auction macro DSPs embed in creative markup and tracking URLs. */
const AUCTION_PRICE_MACRO = '${AUCTION_PRICE}';

/**
* Substitute the `${AUCTION_PRICE}` macro with a clearing price. Mirrors the
* server-side `expand_auction_price_macro`: only the exact clear-price token is
* replaced, so the encrypted `${AUCTION_PRICE:B64}` variant is left intact.
*/
function expandAuctionPriceMacro(markup: string, cpm: number): string {
return markup.includes(AUCTION_PRICE_MACRO)
? markup.split(AUCTION_PRICE_MACRO).join(String(cpm))
: markup;
}

/** A decoded PBS Cache bid: the renderable creative plus its render metadata. */
export interface CachedBid {
adm: string;
width?: number;
height?: number;
price?: number;
}

/**
* Decode a PBS Cache GET response into a renderable bid.
*
* Prebid Cache entries are JSON bid objects (`{ "adm": "<div…>", "w": …, … }`);
* the Prebid Universal Creative's own cache path `JSON.parse`s the response and
* renders `bidObject.adm`, sizing from the cached dimensions. This mirrors that,
* retaining the fields the fallback render needs — creative dimensions (`w`/`h`
* or `width`/`height`) and clearing `price` for macro expansion — rather than
* reducing the payload to a bare `adm` string that forces the first slot format
* and leaves price macros unresolved.
*
* A non-JSON body is treated as raw creative markup (the `{ adm }`-only variant)
* for backward compatibility with caches that store the creative directly.
* Returns `undefined` when the JSON payload carries no usable string `adm`, so
* the caller can decline to render instead of injecting a serialized object.
*/
export function parseCachedBid(body: string): CachedBid | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
// Not JSON — a cache that returned the creative markup directly. No render
// metadata is available, so only the raw markup variant is returned.
return body.trim().length > 0 ? { adm: body } : undefined;
}
if (!parsed || typeof parsed !== 'object') {
// A JSON primitive (string/number/bool) is not a valid cached bid object.
return undefined;
}
const obj = parsed as Record<string, unknown>;
const adm = obj.adm;
if (typeof adm !== 'string' || adm.length === 0) return undefined;

const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
// A zero (or missing) dimension is not usable render metadata; treat it as
// absent so the caller falls back to the slot format rather than sizing to 0.
const dim = (v: unknown): number | undefined => {
const n = num(v);
return n !== undefined && n > 0 ? n : undefined;
};

return {
adm,
// PBS OpenRTB bids carry w/h; the Prebid.js cache format uses width/height.
width: dim(obj.w) ?? dim(obj.width),
height: dim(obj.h) ?? dim(obj.height),
price: num(obj.price),
};
}

/**
* Install the TS → pbRender bridge.
*
Expand All @@ -828,7 +908,9 @@ const TS_DISPLAY_RENDERER =
*
* When `adId` matches a TS server-side bid in `window.tsjs.bids` AND the bid
* has renderable markup, the bridge:
* 1. Uses debug `adm` directly when present, otherwise fetches from PBS Cache.
* 1. Uses the inline `adm` directly when present (the sanitized winning
* creative, now shipped in production), otherwise fetches from PBS Cache
* and extracts `adm` from the cached bid JSON (see `extractCachedAdm`).
* 2. Replies via the MessageChannel port with a `"Prebid Response"`.
* 3. Calls `stopImmediatePropagation()` so Prebid.js does not also process
* the message and log spurious failures.
Expand All @@ -839,12 +921,14 @@ const TS_DISPLAY_RENDERER =
export function installTsRenderBridge(): void {
if (typeof window === 'undefined') return;

// adIds whose PBS Cache render is in flight. `fireWinBillingBeacons` only
// dedups after the async cache fetch resolves, so two Prebid Request messages
// for the same adId arriving before the first fetch settles would both fetch
// and both fire the nurl/burl beacons. Tracking in-flight adIds prevents the
// concurrent double-fire; the entry is cleared once the fetch settles.
const renderingAdIds = new Set<string>();
// `slotId|adId` renders whose PBS Cache fetch is in flight. `fireWinBillingBeacons`
// only dedups after the async fetch resolves, so two Prebid Request messages for
// the same render arriving before the first fetch settles would both fetch and
// both fire the nurl/burl beacons. Tracking the in-flight render prevents the
// concurrent double-fire; the entry is cleared once the fetch settles. The key
// 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<string>();

window.addEventListener('message', (e: MessageEvent) => {
let data: Record<string, unknown>;
Expand All @@ -866,28 +950,26 @@ export function installTsRenderBridge(): void {
const sourceSlotId = slotIdForMessageSource(e.source);
if (!sourceSlotId) return;

// Build reverse map adId → slotId from live window.tsjs.bids.
// Resolve the bid by the requesting slot, not by the first bid whose hb_adid
// matches. hb_adid is not unique per bid: absent PBS Cache, it falls back to a
// creative id a bidder may reuse across slots. A first-match-by-adId lookup
// would resolve every duplicate to one slot, so all but that slot render blank.
const bids = window.tsjs?.bids ?? {};
let slotId: string | undefined;
let matchedBid: (typeof bids)[string] | undefined;
for (const [sid, bid] of Object.entries(bids)) {
if (bid.hb_adid === adId) {
slotId = sid;
matchedBid = bid;
break;
}
}

// Not a TS bid — let Prebid.js handle it.
if (!slotId || !matchedBid) return;
const slotId = sourceSlotId;
const matchedBid = bids[slotId];

// The requesting iframe's slot must own the resolved adId. Without this an
// iframe under slot A could request slot B's hb_adid and receive slot B's
// creative/dimensions while firing slot B's win/billing beacons.
if (slotId !== sourceSlotId) return;
// Not a TS bid, or the requesting slot's bid does not own this adId — let
// Prebid.js handle it. The adId guard also prevents an iframe under slot A from
// pulling slot B's creative and firing slot B's win/billing beacons.
if (!matchedBid || matchedBid.hb_adid !== adId) return;

const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId);
const [width, height] = slot?.formats?.[0] ?? [728, 90];
// Prefer the winning creative's own dimensions; the first configured slot
// format is only a fallback and mis-sizes a multi-size slot whose winner is
// not the first format.
const [fallbackWidth, fallbackHeight] = slot?.formats?.[0] ?? [728, 90];
const width = matchedBid.w ?? fallbackWidth;
const height = matchedBid.h ?? fallbackHeight;

if (matchedBid.adm) {
e.stopImmediatePropagation();
Expand All @@ -902,7 +984,7 @@ export function installTsRenderBridge(): void {
})
);
fireWinBillingBeacons(slotId, matchedBid);
log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from debug adm`);
log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from inline adm`);
return;
}

Expand All @@ -912,34 +994,57 @@ export function installTsRenderBridge(): void {
// TS owns this adId — stop Prebid from also processing it.
e.stopImmediatePropagation();

// Skip a concurrent re-render of the same adId so its win/billing beacons
// fire at most once even before the first cache fetch resolves.
if (renderingAdIds.has(adId)) return;
renderingAdIds.add(adId);
// Skip a concurrent re-render of the same slot's adId so its win/billing
// beacons fire at most once even before the first cache fetch resolves.
const renderingKey = `${slotId}|${adId}`;
if (renderingKeys.has(renderingKey)) return;
renderingKeys.add(renderingKey);

const cacheUrl = `https://${matchedBid.hb_cache_host}${matchedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`;

fetch(cacheUrl, { mode: 'cors' })
.then((res) => (res.ok ? res.text() : Promise.reject(res.status)))
.then((ad) => {
.then((body) => {
// PBS Cache returns the cached bid as a JSON object; decode its creative
// and render metadata the same way the Prebid Universal Creative does.
const cached = parseCachedBid(body);
if (!cached) {
// No renderable creative in the cache payload — decline rather than
// ship a serialized bid document to PUC. Beacons stay unfired.
log.warn(
`[tsjs-gpt] pbRender bridge: PBS Cache response for '${slotId}' had no renderable adm`
);
return;
}
// Resolve the auction-price macro from the cached clearing price, and
// size from the cached bid's own dimensions, falling back to the slot
// format only when the cache omits them.
const ad =
cached.price !== undefined
? expandAuctionPriceMacro(cached.adm, cached.price)
: cached.adm;
port.postMessage(
JSON.stringify({
message: 'Prebid Response',
adId,
ad,
renderer: TS_DISPLAY_RENDERER,
width,
height,
width: cached.width ?? width,
height: cached.height ?? height,
})
);
// Beacons carry the server-expanded ${AUCTION_PRICE} from the auction's
// clearing price, not `cached.price` — the auction result is the
// authoritative clearing price, and the cached copy is only the render
// source. Do not re-expand them here.
fireWinBillingBeacons(slotId, matchedBid);
log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`);
})
.catch((err) => {
log.warn(`[tsjs-gpt] pbRender bridge: PBS Cache fetch failed for '${slotId}'`, err);
})
.finally(() => {
renderingAdIds.delete(adId);
renderingKeys.delete(renderingKey);
});
});
}
Expand Down
Loading
Loading