diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index 63a6d93c..e523fd33 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -83,7 +83,9 @@ pub(super) fn to_abs(settings: &Settings, u: &str) -> Option { } // Helper: rewrite url(...) occurrences inside a CSS style string to first-party proxy. -pub(super) fn rewrite_style_urls(settings: &Settings, style: &str) -> String { +// `base_origin` is prefixed onto the proxy path — empty for root-relative output, +// `https://` for absolute output (see [`build_proxy_url`]). +pub(super) fn rewrite_style_urls(settings: &Settings, style: &str, base_origin: &str) -> String { // naive url(...) rewrite for absolute/protocol-relative URLs let lower = style.to_ascii_lowercase(); let mut out = String::with_capacity(style.len() + 16); @@ -120,7 +122,7 @@ pub(super) fn rewrite_style_urls(settings: &Settings, style: &str) -> String { }; let url_val = &style[qs..qe]; let new_val = if let Some(abs) = to_abs(settings, url_val) { - build_proxy_url(settings, &abs) + build_proxy_url(settings, &abs, base_origin) } else { url_val.to_owned() }; @@ -186,9 +188,19 @@ fn build_signed_url_for( format!("{}?{}", base_path, qs.finish()) } +/// Build a signed first-party proxy URL, prefixing `base_origin` before the +/// `/first-party/proxy` path. An empty `base_origin` yields a root-relative URL +/// (the default, for creatives rendered from the first-party origin); a +/// `https://` origin yields an absolute URL that resolves correctly when +/// the creative is rendered in a foreign origin (e.g. PUC's `srcdoc` under GAM). #[inline] -pub(super) fn build_proxy_url(settings: &Settings, clear_url: &str) -> String { - build_signed_url_for(settings, clear_url, "/first-party/proxy", &[]) +pub(super) fn build_proxy_url(settings: &Settings, clear_url: &str, base_origin: &str) -> String { + build_signed_url_for( + settings, + clear_url, + &format!("{base_origin}/first-party/proxy"), + &[], + ) } #[inline] @@ -200,17 +212,24 @@ pub(super) fn build_proxy_url_with_extras( build_signed_url_for(settings, clear_url, "/first-party/proxy", extra) } +/// Build a signed first-party click URL, prefixing `base_origin` before the +/// `/first-party/click` path. See [`build_proxy_url`] for the origin semantics. #[inline] -pub(super) fn build_click_url(settings: &Settings, clear_url: &str) -> String { - build_signed_url_for(settings, clear_url, "/first-party/click", &[]) +pub(super) fn build_click_url(settings: &Settings, clear_url: &str, base_origin: &str) -> String { + build_signed_url_for( + settings, + clear_url, + &format!("{base_origin}/first-party/click"), + &[], + ) } // Note: previously we exposed canonical without token; now we store the full signed // click URL in data-tsclick and derive canonicals on the client when needed. #[inline] -pub(super) fn proxy_if_abs(settings: &Settings, val: &str) -> Option { - to_abs(settings, val).map(|abs| build_proxy_url(settings, &abs)) +pub(super) fn proxy_if_abs(settings: &Settings, val: &str, base_origin: &str) -> Option { + to_abs(settings, val).map(|abs| build_proxy_url(settings, &abs, base_origin)) } /// Split a srcset/imagesrcset attribute into candidate strings. @@ -263,7 +282,7 @@ pub(super) fn split_srcset_candidates(s: &str) -> Vec<&str> { /// - Proxies absolute or protocol-relative candidates via first-party endpoint /// - Preserves descriptors (e.g., `1x`, `1.5x`, `100w`) /// - Leaves relative candidates unchanged -pub(super) fn rewrite_srcset(settings: &Settings, srcset: &str) -> String { +pub(super) fn rewrite_srcset(settings: &Settings, srcset: &str, base_origin: &str) -> String { let mut out_items: Vec = Vec::new(); for item in split_srcset_candidates(srcset) { let it = item.trim(); @@ -274,7 +293,7 @@ pub(super) fn rewrite_srcset(settings: &Settings, srcset: &str) -> String { let url = parts.next().unwrap_or(""); let descriptor = parts.collect::>().join(" "); let rewritten = if let Some(abs) = to_abs(settings, url) { - build_proxy_url(settings, &abs) + build_proxy_url(settings, &abs, base_origin) } else { url.to_owned() }; @@ -288,9 +307,13 @@ pub(super) fn rewrite_srcset(settings: &Settings, srcset: &str) -> String { } #[inline] -pub(super) fn proxied_attr_value(settings: &Settings, attr_val: Option) -> Option { +pub(super) fn proxied_attr_value( + settings: &Settings, + attr_val: Option, + base_origin: &str, +) -> Option { match attr_val { - Some(v) => proxy_if_abs(settings, &v), + Some(v) => proxy_if_abs(settings, &v, base_origin), None => None, } } @@ -299,7 +322,7 @@ pub(super) fn proxied_attr_value(settings: &Settings, attr_val: Option) /// unified first-party proxy. Relative URLs are left unchanged. #[must_use] pub fn rewrite_css_body(settings: &Settings, css: &str) -> String { - rewrite_style_urls(settings, css) + rewrite_style_urls(settings, css, "") } /// Maximum byte length of creative HTML accepted by [`sanitize_creative_html`]. @@ -492,14 +515,53 @@ pub fn sanitize_creative_html(markup: &str) -> String { String::from_utf8(out).unwrap_or_default() } -/// Rewrite ad creative HTML to first-party endpoints. +/// Rewrite ad creative HTML to first-party endpoints, for creatives rendered +/// from the first-party origin (the `/auction` iframe `srcdoc`). /// - 1x1 `` pixels → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>` /// - Non-pixel absolute images → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>` /// - `', + ...(debugBid ? { debug_bid: { slot_id: 'atf_sidebar_ad' } } : {}), + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + // A pre-existing GAM iframe; the bypass, if it runs, rewrites its src. + const slotEl = document.getElementById('div-atf-sidebar')!; + const gamIframe = document.createElement('iframe'); + gamIframe.src = 'about:blank'; + slotEl.appendChild(gamIframe); + + expect(capturedListener).toBeDefined(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + return gamIframe; + } + + it('does not run the GAM-replace bypass without debug_bid (production)', async () => { + const gamIframe = await fireSlotRenderWithAdm(false); + // No debug_bid ⇒ testing bypass is off; the render bridge handles the creative + // and GAM stays in the loop, so the GAM iframe src is untouched. + expect(gamIframe.src).toBe('about:blank'); + }); + + it('runs the GAM-replace bypass when debug_bid is present (testing)', async () => { + const gamIframe = await fireSlotRenderWithAdm(true); + // debug_bid present ⇒ inject_adm_for_testing on ⇒ direct GAM replace fires, + // rewriting the iframe to the creative URL from the adm. + expect(gamIframe.src).toBe('https://cdn.example/creative.html'); + }); + it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -917,9 +991,12 @@ describe('installTsRenderBridge', () => { it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const mockAd = '
Test Creative
'; + // PBS Cache (returnCreative=false) returns the cached bid as a JSON object; + // the creative lives under `adm`, not as the raw response body. The bridge + // must parse it and forward `adm`, mirroring the Prebid Universal Creative. fetchStub.mockResolvedValue({ ok: true, - text: () => Promise.resolve(mockAd), + text: () => Promise.resolve(JSON.stringify({ adm: mockAd, width: 728, height: 90 })), } as Response); // Capture the bridge's 'message' listener at module-init time. @@ -986,6 +1063,74 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('declines to render when the PBS Cache response carries no adm', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + // A returnCreative=false JSON entry with no `adm` (VAST-only, or malformed). + // The bridge must NOT forward the serialized bid document to PUC. + fetchStub.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ width: 728, height: 90 })), + } as Response); + + const bridgeListener = await captureBridgeListener(); + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + const source = createTrustedSlotIframe(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [fakePort], + source, + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // TS owns the adId so Prebid is still stopped, but with nothing renderable + // the bridge sends no Prebid Response and fires no win/billing beacons. + expect(fetchStub).toHaveBeenCalled(); + expect(stopSpy).toHaveBeenCalled(); + expect(portMessages).toHaveLength(0); + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + + it('renders a non-JSON PBS Cache body as raw creative markup', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const rawAd = '
Raw Cached Creative
'; + // Backward compatibility: a cache that returns the creative markup directly + // (not a JSON bid object) is still rendered as-is. + fetchStub.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(rawAd), + } as Response); + + const bridgeListener = await captureBridgeListener(); + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + const source = createTrustedSlotIframe(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [fakePort], + source, + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(portMessages).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + expect(parsed.ad).toBe(rawAd); + expect(beaconSpy).toHaveBeenCalledTimes(2); + beaconSpy.mockRestore(); + }); + it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { // Concurrent render double-fire guard: two 'Prebid Request' messages for the // same adId can arrive before the first cache fetch settles. The in-flight @@ -1039,18 +1184,22 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); - it('responds with adm without fetching PBS Cache when debug adm is available', async () => { + it('serves inline adm without fetching PBS Cache even when cache coords are present', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const debugAdm = '
Debug Creative
'; + const inlineAdm = '
Inline Creative
'; (window as TestWindow).tsjs = { bids: { homepage_header: { hb_adid: 'debug-adid', hb_bidder: 'mocktioneer', hb_pb: '0.20', + // Production shape: cache coordinates ARE present, but the bridge must + // prefer the local inline adm and skip the PBS Cache fetch. + hb_cache_host: 'cache.example.com', + hb_cache_path: '/pbc/v1/cache', nurl: 'https://debug.example/win', burl: 'https://debug.example/bill', - adm: debugAdm, + adm: inlineAdm, }, }, adSlots: [ @@ -1103,7 +1252,7 @@ describe('installTsRenderBridge', () => { const parsed = JSON.parse(portMessages[0]) as Record; expect(parsed.message).toBe('Prebid Response'); expect(parsed.adId).toBe('debug-adid'); - expect(parsed.ad).toBe(debugAdm); + expect(parsed.ad).toBe(inlineAdm); expect(parsed.width).toBe(728); expect(parsed.height).toBe(90); expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); diff --git a/docs/superpowers/plans/2026-07-13-ssat-render-inline-creative.md b/docs/superpowers/plans/2026-07-13-ssat-render-inline-creative.md new file mode 100644 index 00000000..07df67f5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-ssat-render-inline-creative.md @@ -0,0 +1,220 @@ +# SSAT Inline Creative Rendering — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render SSAT-winning creatives from the copy trusted-server already holds (no render-time PBS Cache round trip), while keeping GAM in the loop. + +**Architecture:** Always include the winning `adm` in `window.tsjs.bids` so the existing `installTsRenderBridge` serves it locally when GAM's Prebid Universal Creative fires. Keep `hb_cache_*` as the fallback for an _absent_ `adm`. Keep the verbose `debug_bid` blob behind the testing flag, and gate the GAM-bypass path (`injectAdmIntoSlot`) on the per-bid `debug_bid` field — no global flag, no `TsjsApi` change. + +**Tech Stack:** Rust (`trusted-server-core`, wasm32-wasip1 via Viceroy), TypeScript (`trusted-server-js`, vitest). + +**Spec:** `docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md` + +--- + +## File Structure + +| File | Responsibility | Change | +| -------------------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/publisher.rs` | Build `window.tsjs.bids` | Always insert `adm`; rename `include_adm`→`include_debug_bid` (gates only the blob) | +| `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` | Client render paths | Gate `injectAdmIntoSlot` on `bid.adm && bid.debug_bid` | +| `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` | JS tests | Rename "debug adm"→"inline/local adm"; add bypass-gate test | + +--- + +## Task 1: `build_bid_map` — always include `adm`, gate only `debug_bid` + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` (`build_bid_map` ~1933; `write_bids_to_state` ~846; callers) +- Test: same file, `#[cfg(test)] mod tests` + +- [ ] **Step 1: Write failing test** — `adm` present for a winner regardless of the debug flag; `debug_bid` only when the flag is set. + +```rust +#[test] +fn build_bid_map_always_includes_adm_and_gates_debug_bid() { + let mut winning = std::collections::HashMap::new(); + // Reuse the existing `make_bid(slot_id, price, bidder, ad_id, nurl, burl)` + // helper (~:3969); it sets `creative: None`, so set the creative here. + let mut bid = make_bid("ad-header-0", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill"); + bid.creative = Some("
x
".to_string()); + winning.insert("ad-header-0".to_string(), bid); + // include_debug_bid = false (production) + let map = build_bid_map(&winning, PriceGranularity::Dense, false); + let slot = map["ad-header-0"] + .as_object() + .expect("should contain an object for the winning slot"); + assert_eq!( + slot["adm"], + serde_json::json!("
x
"), + "should include creative markup for local rendering" + ); + assert!( + !slot.contains_key("debug_bid"), + "should omit the debug_bid blob when the testing flag is off" + ); +} +``` + +- [ ] **Step 2: Run — expect FAIL** (signature is 3-arg with `include_adm` semantics; adm currently gated off). + +Run: `cargo test-fastly build_bid_map_always_includes_adm_and_gates_debug_bid` + +- [ ] **Step 3: Implement** — rename the param and split the gating: + +```rust +pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap, + granularity: crate::price_bucket::PriceGranularity, + include_debug_bid: bool, +) -> serde_json::Map { + // ... unchanged hb_* / cache / nurl / burl inserts ... + // Always include the creative for local rendering. + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + // Verbose debug blob only under the testing flag. + if include_debug_bid { + obj.insert("debug_bid".to_string(), serde_json::json!({ /* unchanged blob */ })); + } + // ... +} +``` + +- [ ] **Step 4: Run — expect PASS.** + +- [ ] **Step 5: Update call sites** so the crate compiles (real call graph): + - `write_bids_to_state` (`~846`): rename its `inject_adm: bool` param to `include_debug_bid`; call `build_bid_map(winning_bids, price_granularity, include_debug_bid)` (`~853`). `build_bids_script` is **unchanged**. + - Callers `~695` / `~1241`: no change (already pass `settings.debug.inject_adm_for_testing`). + - `handle_page_bids` (`~2390`): `build_bid_map(&winning_bids, co_config.price_granularity, settings.debug.inject_adm_for_testing)`. + - Pure-test caller (`~4043`): `build_bid_map(&winning_bids, PriceGranularity::Dense, false)`. + +- [ ] **Step 6: Update any test that asserted "no adm" on the production path** — production now always carries `adm`; adjust expectations to the new behavior. + +- [ ] **Step 7: Run** `cargo check-fastly` — expect clean. + +- [ ] **Step 8: Commit** — `git commit -m "Always include adm in bid map; gate only debug_bid blob"` + +--- + +## Task 2: Escaping regression — hostile `adm` cannot break out of `` + `U+2028` adm is neutralized in the emitted script. + +```rust +#[test] +fn build_bids_script_escapes_hostile_adm() { + let mut winning = std::collections::HashMap::new(); + let mut bid = make_bid("s", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill"); + // Both line/paragraph separators — the spec promises escaping for each. + bid.creative = Some("\u{2028}\u{2029}".to_string()); + winning.insert("s".to_string(), bid); + let map = build_bid_map(&winning, PriceGranularity::Dense, false); + let script = build_bids_script(&map); + assert!( + !script.contains("` breakout and `U+2028/2029`. Pinned by a + line-separator escaping test. + +Frame isolation of the rendered creative is **not** guaranteed by TS on the +bridge path: `injectAdmIntoSlot` sets `sandbox=ADM_IFRAME_SANDBOX`, but the +bridge renderer hands `adm` to the PUC-provided `mkFrame`, which TS neither sets +nor verifies a sandbox on. Bridge isolation therefore depends on the Prebid +Universal Creative implementation, not on TS. + +## Components changed + +| Unit | Change | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build_bid_map` (Rust) | Sanitize+rewrite `bid.creative` (1 MiB cap) before inserting `adm`; omit when rejected. Takes `&Settings`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` / `write_bids_to_state` callers | Thread `&Settings`; pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `installTsRenderBridge` | Decode PBS Cache JSON, extract `adm` (`extractCachedAdm`); decline when absent. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; realistic `returnCreative=false` cache payload + malformed/raw-markup coverage. | + +No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. + +## Data flow (after) + +``` +SSAT auction → winner (bid.creative held) → build_bid_map sanitizes+rewrites → inserts adm + → build_bids_script (html_escape_for_script) → window.tsjs.bids + → hb_pb targeting → GAM competes + ├ GAM picks TS line item → PUC "Prebid Request" + │ → bridge replies with inline adm → RENDER (no round trip) + beacons + │ → (adm absent) → PBS Cache fetch → decode JSON → extract adm → RENDER (fallback) + └ GAM has higher demand → GAM serves its own creative +``` + +## Precondition + +This changes only the render bridge's _data source_ — local `adm` vs a PBS Cache +fetch — **when GAM's Prebid line item already serves the PUC**. It does not +change GAM competition, nor whether the PUC fires. A publisher without Prebid +line items in GAM sees no behavioral change. + +## Testing + +- **Rust**: `build_bid_map` includes a sanitized `adm` for winners on every path; + a hostile `adm` has its `