From 2b326e778c4c76404b75602793f38173527ee1b9 Mon Sep 17 00:00:00 2001
From: jevansnyc
Date: Wed, 15 Apr 2026 20:45:20 +0200
Subject: [PATCH 01/34] Add server-side ad templates design spec
Co-Authored-By: Claude Sonnet 4.6
---
...6-04-15-server-side-ad-templates-design.md | 363 ++++++++++++++++++
1 file changed, 363 insertions(+)
create mode 100644 docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md
diff --git a/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md
new file mode 100644
index 000000000..454f37641
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md
@@ -0,0 +1,363 @@
+# Server-Side Ad Templates Design
+
+*April 2026*
+
+---
+
+## 1. Problem Statement
+
+Today's display ad pipeline on most publisher sites is structurally sequential
+and browser-bound:
+
+1. Page HTML arrives at browser
+2. Prebid.js (~300KB) downloads and parses
+3. Smart Slots SDK scans the DOM to discover ad placements
+4. `addAdUnits()` registers slot definitions
+5. Prebid auction fires from the browser (~80–150ms RTT to SSPs)
+6. Bids return (~1,000–1,500ms window)
+7. GPT `setTargeting()` + `refresh()` fires
+8. GAM creative renders
+
+**Total time to ad visible: ~3,100ms.**
+
+The browser is the slowest possible place to run an auction. It must first download and parse
+multiple SDKs, scan the DOM to discover what ad slots exist, and then fire SSP requests over
+a consumer internet connection with high and variable latency.
+
+Trusted Server sits at the Fastly edge — milliseconds from the user, with data-center-to-data-center
+RTT to Prebid Server (~20–30ms vs ~80–150ms from a browser). The server knows, from the request
+URL alone, exactly which ad slots are available on any given page. There is no reason to wait for
+the browser.
+
+---
+
+## 2. Goal
+
+Enable Trusted Server to:
+
+1. Match an incoming page request URL against a set of pre-configured slot templates
+2. Immediately fire the full server-side auction (all providers: PBS, APS, future wrappers) in
+ parallel with the origin HTML fetch — before the browser receives a single byte
+3. Inject GPT slot definitions into `` so the client can define slots without any SDK
+4. Return pre-collected winning bids to the browser's lightweight `/auction` POST before the
+ browser would have even finished parsing Prebid.js
+5. Eliminate Prebid.js from the client entirely
+
+**Target time to ad visible: ~1,200ms. Net saving: ~2,000ms.**
+
+---
+
+## 3. Non-Goals
+
+- Eliminating client-side GPT / Google Ad Manager — GAM remains in the rendering pipeline
+ for Phase 1. The GAM call (`securepubads.g.doubleclick.net`) moves server-side in a future phase.
+- Dynamic slot discovery (reading the DOM) — this design commits to pre-defined, URL-matched
+ slot templates. Smart Slots' dynamic injection behavior is replaced by server knowledge.
+- Changing the `AuctionOrchestrator` internally — the orchestrator already handles parallel
+ provider fan-out. This design adds a new trigger point, not new auction logic.
+
+---
+
+## 4. Architecture
+
+### 4.1 New File: `creative-opportunities.toml`
+
+A new config file at the repo root, alongside `trusted-server.toml`. It holds all slot templates:
+page pattern matching rules, ad formats, floor prices, and GAM targeting key-values. Bidder-level
+params (placement IDs, account IDs) live in Prebid Server stored requests, keyed by slot ID — not
+in this file.
+
+Loaded at build time via `include_str!()`, parsed into `Vec` at startup.
+Ad ops can edit this file independently of server configuration.
+
+`floor_price` is the publisher-owned hard floor per slot — the source of truth for the minimum
+acceptable bid price, enforced at the edge before bids reach the ad server. Any bid below the
+floor is discarded at the orchestrator level before it enters `__ts_bids`. SSPs may apply their
+own dynamic floors independently within their platforms; this floor is the publisher's baseline
+that supersedes all other floor logic by virtue of being enforced earliest in the pipeline.
+
+**Schema:**
+
+```toml
+[[slot]]
+id = "atf_sidebar_ad"
+page_patterns = ["/20*/"]
+formats = [{ width = 300, height = 250 }]
+floor_price = 0.50
+
+[slot.targeting]
+pos = "atf"
+zone = "atfSidebar"
+
+[[slot]]
+id = "below-content-ad"
+page_patterns = ["/20*/"]
+formats = [{ width = 300, height = 250 }, { width = 728, height = 90 }]
+floor_price = 0.25
+
+[slot.targeting]
+pos = "btf"
+zone = "belowContent"
+
+[[slot]]
+id = "ad-homepage-0"
+page_patterns = ["/", "/index.html"]
+formats = [{ width = 970, height = 250 }, { width = 728, height = 90 }]
+floor_price = 1.00
+
+[slot.targeting]
+pos = "atf"
+zone = "homepage"
+slot_index = "0"
+```
+
+**Rust type:**
+
+```rust
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct CreativeOpportunitySlot {
+ pub id: String,
+ pub page_patterns: Vec,
+ pub formats: Vec,
+ pub floor_price: Option,
+ pub targeting: HashMap,
+}
+```
+
+### 4.2 URL Pattern Matching
+
+At request time, TS matches the request path against each slot's `page_patterns`. Patterns are
+glob-style strings:
+
+- `/20*/` — matches all date-prefixed article paths (e.g., `/2024/01/my-article/`)
+- `/` — matches the homepage exactly
+- `/index.html` — exact match
+
+Multiple slots can match a single URL. All matching slots are collected and fed into a single
+auction as separate impressions. Pattern matching is purely in-memory against the pre-parsed
+config — sub-millisecond.
+
+### 4.3 Auction Trigger
+
+When slots are matched, TS immediately calls `AuctionOrchestrator::run_auction()` with the
+matched slots converted to `AdSlot` objects. This happens at request receipt time — in parallel
+with the origin fetch.
+
+The orchestrator's existing behaviour is unchanged:
+- All providers (PBS, APS, any configured wrappers) are dispatched simultaneously
+- Per-provider timeout budgets are enforced from the remaining auction deadline
+- Floor price filtering, bid unification, and winning bid selection are applied as today
+- PBS resolves bidder params from its stored requests by slot ID — no bidder params travel
+ through TS or the browser
+
+**On NextJS 14 (buffered mode):** TS must buffer the full origin response before forwarding.
+This gives the auction the entire origin response time (~150–400ms typical) to run before
+any HTML is forwarded. In practice, bids are often collected before origin even responds.
+
+**On NextJS 16 (streaming mode):** TS streams HTML chunks to the browser immediately. The
+auction runs in parallel. Bid injection into `` must complete before the `` tag
+is forwarded. If the auction has not returned by the time `` is encountered, TS waits
+up to the remaining auction budget, then flushes with whatever bids have arrived (partial
+results) or no targeting if timed out. Content after `` is never held.
+
+### 4.4 Head Injection
+
+TS injects two separate ``, not
+> raw string interpolation.
-Prebid.js is eliminated. The client-side ad bootstrap is replaced by a small inline script
-(~20 lines) that reads `__ts_ad_slots` and `__ts_bids` and drives GPT directly:
+> **Cache contract:** Any response with `__ts_bids` injected is per-user data and must
+> not be cached. TS sets `Cache-Control: private, no-store` on the response before
+> forwarding, overriding any conflicting cache headers from the publisher origin.
+> `Surrogate-Control` and `Fastly-Surrogate-Control` are also stripped.
+
+### 4.5 Win Notifications
+
+Win notification responsibilities are split by where the truth lives:
+
+**`nurl` (SSP win event) — fired server-side.** When the orchestrator selects a winning
+bid, TS fires a fire-and-forget background HTTP request to `nurl` from the edge
+(edge→SSP RTT ~20–30ms, no auction-path latency cost). A per-integration switch
+(`[integrations.prebid].fire_nurl_at_edge`, default `true`) handles cases where the PBS
+deployment already fires win events internally to avoid double-firing. APS win
+notification follows its own spec.
+
+**`burl` (billing event) — fired client-side.** `burl` is embedded per slot in
+`__ts_bids` (see §4.4). The `__tsAdInit` script registers a GPT `slotRenderEnded`
+listener after defining slots. On render: if `!event.isEmpty` and
+`event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid`, the client fires `burl`
+via `navigator.sendBeacon`. This confirms both that the ad rendered and that our specific
+Prebid bid (not a direct deal or backfill) won the GAM line item match.
+
+### 4.6 Client Residual
+
+Prebid.js is eliminated. The client-side ad bootstrap is replaced by a small inline
+script (~30 lines) that reads `__ts_ad_slots` and `__ts_bids`, drives GPT directly, and
+handles billing notifications:
```javascript
-window.__tsAdInit = function() {
- var slots = window.__ts_ad_slots || [];
- var bids = window.__ts_bids || {};
- googletag.cmd.push(function() {
- slots.forEach(function(slot) {
- var gptSlot = googletag.defineSlot(slot.id, slot.formats, slot.id)
- .addService(googletag.pubads());
+window.__tsAdInit = function () {
+ var slots = window.__ts_ad_slots || []
+ var bids = window.__ts_bids || {}
+ googletag.cmd.push(function () {
+ slots.forEach(function (slot) {
+ var gptSlot = googletag
+ .defineSlot(slot.gam_unit_path, slot.formats, slot.div_id)
+ .addService(googletag.pubads())
// Apply static targeting from config
- Object.entries(slot.targeting).forEach(function([k, v]) {
- gptSlot.setTargeting(k, v);
- });
+ Object.entries(slot.targeting).forEach(function ([k, v]) {
+ gptSlot.setTargeting(k, v)
+ })
// Apply pre-won bid targeting if available
- var bidTargeting = bids[slot.id] || {};
- Object.entries(bidTargeting).forEach(function([k, v]) {
- gptSlot.setTargeting(k, v);
- });
- });
- googletag.pubads().enableSingleRequest();
- googletag.enableServices();
- googletag.pubads().refresh();
- });
-};
+ var bidData = bids[slot.id] || {}
+ ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (key) {
+ if (bidData[key]) gptSlot.setTargeting(key, bidData[key])
+ })
+ })
+ googletag.pubads().enableSingleRequest()
+ googletag.enableServices()
+ // Fire burl on confirmed render
+ googletag.pubads().addEventListener('slotRenderEnded', function (event) {
+ var slotId = event.slot.getSlotElementId()
+ var bidData = bids[slotId] || {}
+ if (
+ !event.isEmpty &&
+ bidData.burl &&
+ event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid
+ ) {
+ navigator.sendBeacon(bidData.burl)
+ }
+ })
+ googletag.pubads().refresh()
+ })
+}
```
-This script is part of the `tsjs-gpt` integration bundle, injected by TS into every matching
-page response alongside the existing GPT integration.
+This script is part of the existing `gpt` integration bundle
+(`crates/js/lib/src/integrations/gpt/index.ts`), extending the existing GPT shim.
+Injected via the `gpt` head injector alongside `window.__ts_ad_slots`.
---
@@ -238,21 +440,26 @@ t=0ms GET ts.publisher.com/article arrives at Fastly edge
t=1ms URL matched against creative-opportunities.toml
Slots matched: [atf_sidebar_ad, below-content-ad, section_ad]
+ Consent check: TCF consent present → auction proceeds
t=2ms AuctionOrchestrator.run_auction() called
- PBS + APS dispatched in parallel
+ PBS + APS dispatched in parallel via send_async()
Edge→PBS RTT: ~20–30ms
-t=2ms Origin fetch dispatched in parallel
+t=2ms Origin fetch dispatched via send_async() in parallel
+
+t=2ms window.__ts_ad_slots script assembled from config (no auction needed)
t=150ms Origin HTML arrives at edge (NextJS 14: buffered)
+ Auction still running; origin response held at edge
-t=502ms Auction timeout fires (500ms budget)
- Winning bids collected
+t=502ms Auction deadline fires (500ms budget)
+ Winning bids collected; nurl fired as background requests
-t=502ms injection assembled:
- - window.__ts_ad_slots (from config, available at t=1ms)
- - window.__ts_bids (from auction results)
+t=502ms HtmlProcessorConfig constructed with bid results captured
+ injection assembled:
+ - window.__ts_ad_slots (from config, ready at t=2ms)
+ - window.__ts_bids (from auction results; Cache-Control: private, no-store set)
t=502ms HTML forwarded to browser with injected
@@ -270,7 +477,7 @@ t=822ms GET /gampad/ads
t=922ms Creative fetch
-t=1222ms Creative sub-resources + paint
+t=1222ms Creative sub-resources + paint; burl fired via slotRenderEnded
AD VISIBLE ~1200ms
```
@@ -279,18 +486,23 @@ t=1222ms Creative sub-resources + paint
## 6. Performance Summary
-| Stage | Client-side today | With TS templates | Saving |
-|---|---|---|---|
-| Script load chain | ~700ms | ~40ms (tsjs only) | -660ms |
-| Script parse/JIT | ~280ms | ~10ms | -270ms |
-| Sequential SDK hops | ~200ms | 0 | -200ms |
-| Auction window | ~1,500ms | ~500ms | -1,000ms |
-| GAM + creative | ~570ms | ~570ms | — |
-| **Total** | **~3,250ms** | **~1,200ms** | **~2,000ms** |
+| Stage | Client-side today | With TS templates | Saving |
+| ------------------- | ----------------- | ----------------- | ------------ |
+| Script load chain | ~700ms | ~40ms (tsjs only) | -660ms |
+| Script parse/JIT | ~280ms | ~10ms | -270ms |
+| Sequential SDK hops | ~200ms | 0 | -200ms |
+| Auction window | ~1,500ms | ~500ms | -1,000ms |
+| GAM + creative | ~570ms | ~570ms | — |
+| TTFB penalty¹ | 0 | up to +350ms | - |
+| **Total** | **~3,250ms** | **~1,200ms** | **~2,000ms** |
+
+¹ Buffered mode only: the origin response is held until the auction resolves. For fast
+origins (<150ms) and a 500ms auction deadline, TTFB may increase by up to 350ms. This
+tradeoff is net-positive on revenue. The streaming mode (NextJS 16) has no TTFB penalty.
-Auction RTT improvement: browser fires SSP requests at 80–150ms RTT; edge fires at 20–30ms.
-Auction timeout can drop from 1,000–1,500ms to 500ms while still collecting more complete
-results, because edge→PBS latency is ~5–7x lower.
+Auction RTT improvement: browser fires SSP requests at 80–150ms RTT; edge fires at
+20–30ms. Auction timeout can drop from 1,000–1,500ms to 500ms while still collecting
+more complete results, because edge→PBS latency is ~5–7x lower.
---
@@ -299,24 +511,42 @@ results, because edge→PBS latency is ~5–7x lower.
### New
- `creative-opportunities.toml` — slot template config file
-- `crates/trusted-server-core/src/creative_opportunities.rs` — config types, TOML parsing,
- URL pattern matching, slot-to-`AdSlot` conversion
-- `build.rs` update — `include_str!()` for `creative-opportunities.toml`
-- Request handler modification — match slots at request receipt, trigger orchestrator immediately,
- hold result for head injection
-- `tsjs-gpt` integration update — `__tsAdInit` bootstrap replaces Prebid.js ad unit setup
+- `crates/trusted-server-core/src/creative_opportunities.rs` — config types, TOML
+ parsing, URL glob matching, slot-to-`AdSlot` conversion, price bucketing
+- `crates/trusted-server-core/build.rs` — `include_str!()` for
+ `creative-opportunities.toml`; startup slot-ID validation
+- `crates/trusted-server-core/src/price_bucket.rs` — Prebid price granularity tables
+ (dense default; publisher-configurable); converts raw CPM `f64` to `hb_pb` string
### Modified
-- `crates/trusted-server-core/src/integrations/prebid.rs` head injector — emit
- `window.__ts_ad_slots` from matched slots
-- `crates/trusted-server-core/src/html_processor.rs` — inject `window.__ts_bids` once auction
- results are available, before ``
-- `trusted-server.toml` — add `creative_opportunities_path` config key pointing to the new file
+- **`crates/trusted-server-core/src/publisher.rs`** — primary structural change:
+ - Convert `handle_publisher_request` from `fn` to `async fn`
+ - Switch origin fetch from `.send()` to `.send_async()` (returns
+ `PlatformPendingRequest`)
+ - Add `orchestrator: &AuctionOrchestrator` parameter
+ - Match slots, check consent, fire auction and origin fetch concurrently
+ - Await both and construct `HtmlProcessorConfig` with resolved bid results
+- **`crates/trusted-server-adapter-fastly/src/main.rs`** — update `route_request` call
+ site to `.await` the now-async publisher handler; pass orchestrator reference
+- **`crates/trusted-server-core/src/html_processor.rs`** — inject `window.__ts_bids`
+ before `` via `el.on_end_tag()` on the `` element; set
+ `Cache-Control: private, no-store` header on injection; HTML-escape bid JSON
+- **`crates/trusted-server-core/src/integrations/gpt.rs`** — extend head injector to
+ emit `window.__ts_ad_slots` from matched slots (not `prebid.rs`); emit `__tsAdInit`
+ bootstrap script
+- **`crates/js/lib/src/integrations/gpt/index.ts`** — add `__tsAdInit` function and
+ `slotRenderEnded` burl-firing logic to the existing GPT shim
+- **`crates/trusted-server-core/src/integrations/prebid.rs`** — add
+ `fire_nurl_at_edge` config key; add nurl fire-and-forget call in orchestrator result
+ handling
+- **`trusted-server.toml`** — add `[creative_opportunities]` section
+- **`crates/trusted-server-core/src/settings.rs`** — add `CreativeOpportunitiesConfig`
+ to `Settings`
### Unchanged
-- `AuctionOrchestrator` — no internal changes; new call site only
+- `AuctionOrchestrator` internals — no changes; new call site only
- PBS stored request configuration — bidder params remain in PBS, keyed by slot ID
- GAM line item configuration — targeting key-values pass through unchanged
@@ -324,40 +554,66 @@ results, because edge→PBS latency is ~5–7x lower.
## 8. Edge Cases
-**No slots match the URL** — auction is not fired. Head injection emits neither global. GPT
-bootstrap detects empty `__ts_ad_slots` and skips initialization. Page loads normally with no
-ad stack.
+**No slots match the URL** — auction is not fired. Neither global is emitted. The page
+loads with no TS ad stack; existing client-side Prebid/GPT flow runs unmodified (for
+publishers in dual-mode rollout).
+
+**Consent absent or denied** — auction is not fired. Neither global is emitted.
+`Cache-Control: private, no-store` is still set (to prevent caching the consent-negative
+response if personalised ads were previously served). Page loads normally; GAM runs its
+own auction without Prebid targeting.
+
+**Auction times out with partial results** — `__ts_bids` is populated with whatever bids
+arrived before the deadline. Slots with no bid are omitted. GPT fires without pre-set
+targeting for those slots; GAM falls back to its own auction for them.
+
+**Auction times out with zero results** — `__ts_bids` is an empty object `{}`. All slots
+fire GAM without bid targeting. No revenue impact beyond the timeout scenario itself.
-**Auction times out with partial results** — `__ts_bids` is populated with whatever bids arrived
-before the deadline. Slots with no bid omitted. GPT fires without pre-set targeting for those slots;
-GAM falls back to its own auction.
+**Origin is slow (NextJS 14, buffered)** — auction has more time; results more likely to
+be complete. TTFB impact is bounded by the origin latency, not additive to it.
-**Auction times out with zero results** — `__ts_bids` is an empty object `{}`. All slots fire
-GAM without bid targeting. No revenue impact beyond the timeout scenario itself (same as today's
-fallback).
+**NextJS 16 streaming** — `el.on_end_tag()` on `` gates injection. TS waits up to
+the remaining `auction_timeout_ms` budget, then flushes. Content after `` is never
+held. If the auction resolves before `` is encountered (common case), injection is
+zero-latency.
-**Origin is slow (NextJS 14, buffered)** — auction has more time; results more likely to be
-complete. No change to streaming behavior.
+**`creative-opportunities.toml` missing or malformed** — startup fails with a clear
+error. No silent degradation.
-**NextJS 16 streaming** — TS must flush `` before `` tag passes through. If auction
-not yet complete, TS waits up to `auction_timeout_ms` from the config, then flushes. Content
-streaming resumes immediately after `` regardless of bid state.
+**Config empty (zero slots)** — treated as "no match" for all URLs; auction never fires.
+No error. Useful as a kill-switch: deploying an empty `creative-opportunities.toml`
+disables the feature without a code change.
-**`creative-opportunities.toml` missing or malformed** — startup fails with a clear error.
-No silent degradation.
+**Slot ID not found in PBS stored requests** — PBS returns a no-bid for that slot. Slot
+is omitted from `__ts_bids`. The remaining slots proceed normally.
---
## 9. Open Questions
-1. **URL pattern coverage** — does `/20*/` cover all article paths, or are there
+1. **URL pattern coverage** — does `/20**` cover all article paths, or are there
non-date-prefixed article URLs? Publisher to confirm.
2. **PBS stored request setup** — slot IDs in `creative-opportunities.toml` must have
- corresponding stored requests configured in the publisher's PBS instance before this goes live.
-3. **Homepage slot count** — the example shows slots 0 and 1. Are there slots 2–5 following
- the same pattern? Slot IDs and count to be confirmed with ad ops.
-4. **Auction timeout for server-side trigger** — current `[integrations.prebid].timeout_ms`
- is 1,000ms. Recommend reducing to 500ms for server-side triggered auctions given the
- lower edge→PBS RTT. Separate config key or override on the new trigger path?
-5. **`tsjs-gpt` bootstrap delivery** — the `__tsAdInit` script needs to fire after GPT.js
- loads. Confirm injection order with the existing GPT integration head injection.
+ corresponding stored requests configured in the publisher's PBS instance before this
+ goes live.
+3. **Homepage slot count** — the example shows slots 0 and 1. Are there additional slots
+ following the same pattern? Slot IDs and count to be confirmed with ad ops.
+4. **Auction timeout** — ✅ Resolved: new dedicated key
+ `[creative_opportunities].auction_timeout_ms` with fallback to `[auction].timeout_ms`.
+ Per-provider ceilings (`[integrations.prebid].timeout_ms`,
+ `[integrations.aps].timeout_ms`) remain unchanged; the orchestrator's existing
+ `min(remaining_budget, provider_timeout)` logic applies.
+5. **KV-backed config migration path** — Phase 1 ships with `include_str!()` for
+ simplicity and cost. When ad ops require live slot edits between deploys, the migration
+ path is: load from `services.kv_store()` at request time with a compiled-in fallback.
+ Design tracked as a follow-up before Phase 2.
+6. **Phase 2 server-side GAM** — The real latency ceiling is the GAM call
+ (`securepubads.g.doubleclick.net`). Phase 2 routes the GAM ad request through the edge
+ (securepubads proxy + creative bundling), eliminating the last browser→Google hop. The
+ Phase 1 architecture is designed to be shape-compatible with this: `__ts_ad_slots`
+ gives the edge the full slot inventory it needs to build a server-side GAM request.
+7. **`tsjs-gpt` bootstrap delivery** — ✅ Resolved: `__tsAdInit` is part of the existing
+ `gpt` integration bundle, not a new integration. Injection order: `window.__ts_ad_slots`
+ → existing GPT shim → `__tsAdInit` — all emitted by the `gpt` head injector in a single
+ `".to_string()
+ ),
+ ad_bids_script: None,
+ };
+ let mut processor = create_html_processor(config);
+ let output = processor
+ .process_chunk(b"T", true)
+ .expect("should process");
+ let html = std::str::from_utf8(&output).expect("should be utf8");
+ assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots");
+ }
+
+ #[test]
+ fn injects_bids_before_end_of_head() {
+ let bids_script = "";
+ let config = HtmlProcessorConfig {
+ origin_host: "origin.example.com".to_string(),
+ request_host: "example.com".to_string(),
+ request_scheme: "https".to_string(),
+ integrations: IntegrationRegistry::empty_for_tests(),
+ ad_slots_script: None,
+ ad_bids_script: Some(bids_script.to_string()),
+ };
+ let mut processor = create_html_processor(config);
+ let output = processor
+ .process_chunk(b"T", true)
+ .expect("should process");
+ let html = std::str::from_utf8(&output).expect("should be utf8");
+ assert!(html.contains("window.__ts_bids"), "should inject bids");
+ let bids_pos = html.find("window.__ts_bids").expect("should find bids");
+ let end_head_pos = html.find("").expect("should find ");
+ assert!(bids_pos < end_head_pos, "bids script should appear before ");
+ }
+ ```
+
+ Run: `cargo test -p trusted-server-core html_processor`
+ Expected: compile error (no `ad_slots_script`/`ad_bids_script` fields, no `empty_for_tests()`)
+
+- [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`**
+
+ In `registry.rs`, add:
+
+ ```rust
+ #[cfg(test)]
+ impl IntegrationRegistry {
+ pub fn empty_for_tests() -> Self {
+ // Minimal registry with no integrations for unit testing html_processor
+ Self {
+ inner: Arc::new(RegistryInner {
+ proxies: Default::default(),
+ attribute_rewriters: Default::default(),
+ script_rewriters: Vec::new(),
+ html_post_processors: Vec::new(),
+ head_injectors: Vec::new(),
+ metadata: Default::default(),
+ })
+ }
+ }
+ }
+ ```
+
+ (Adjust field names to match the actual `RegistryInner` struct.)
+
+- [ ] **Step 3: Add fields to `HtmlProcessorConfig`**
+
+ ```rust
+ pub struct HtmlProcessorConfig {
+ pub origin_host: String,
+ pub request_host: String,
+ pub request_scheme: String,
+ pub integrations: IntegrationRegistry,
+ /// Pre-computed `` for matched slots.
+ /// Injected at open, before integration head inserts. `None` when no slots matched.
+ pub ad_slots_script: Option,
+ /// Pre-computed `` for winning bids.
+ /// Injected immediately before via on_end_tag(). `None` when auction not run.
+ pub ad_bids_script: Option,
+ }
+ ```
+
+ Update `from_settings` to initialize `ad_slots_script: None, ad_bids_script: None`.
+
+- [ ] **Step 4: Inject `__ts_ad_slots` at head-open AND register `on_end_tag` for `__ts_bids`**
+
+ In `create_html_processor`, within the EXISTING single `element!("head", ...)` handler, make two changes:
+ 1. Prepend the ad slots script BEFORE the existing integration inserts:
+
+ ```rust
+ // NEW: inject __ts_ad_slots first
+ if let Some(ref slots_script) = ad_slots_script {
+ snippet.push_str(slots_script);
+ }
+ // ... existing: for insert in integrations.head_inserts(&ctx) { ... }
+ ```
+
+ 2. After `el.prepend(...)`, register the end-tag handler for `__ts_bids`:
+ ```rust
+ // Register on_end_tag handler for __ts_bids injection before
+ if let Some(bids_script) = ad_bids_script.clone() {
+ el.on_end_tag(move |end_tag| {
+ end_tag.before(&bids_script, ContentType::Html);
+ Ok(())
+ })?;
+ }
+ ```
+
+ Both changes live inside the same `element!("head", ...)` closure — no second handler needed.
+
+ Capture `ad_slots_script` and `ad_bids_script` into the closure the same way as `injected_tsjs`:
+
+ ```rust
+ let ad_slots_script = config.ad_slots_script.clone();
+ let ad_bids_script = config.ad_bids_script.clone();
+ ```
+
+ > **lol_html `on_end_tag` API note:** `Element::on_end_tag(handler)` is available in lol_html ≥2.0. The handler receives `&mut EndTag` and must return `Result<(), Box>`. Use `ContentType::Html` so the injected `", escaped)
+ }
+
+ pub(crate) fn build_ad_bids_script(
+ winning_bids: &std::collections::HashMap,
+ price_granularity: crate::price_bucket::PriceGranularity,
+ ) -> String {
+ let bids_map: serde_json::Map = winning_bids
+ .iter()
+ .filter_map(|(slot_id, bid)| {
+ let cpm = bid.price?;
+ let entry = serde_json::json!({
+ "hb_pb": price_bucket(cpm, price_granularity),
+ "hb_bidder": bid.bidder,
+ "hb_adid": bid.ad_id.as_deref().unwrap_or(""),
+ "burl": bid.burl,
+ });
+ Some((slot_id.clone(), entry))
+ })
+ .collect();
+ let json = serde_json::to_string(&serde_json::Value::Object(bids_map))
+ .expect("should serialize bids");
+ let escaped = html_escape_for_script(&json);
+ format!("", escaped)
+ }
+
+ /// HTML-escape a JSON string for safe inline `"
+ .to_string(),
+ // __tsAdInit definition — reads window.__ts_ad_slots / __ts_bids at call time.
+ concat!(
+ ""
+ ).to_string(),
+ ]
+ }
+ }
+ ```
+
+- [ ] **Step 3: Run tests**
+
+ Run: `cargo test -p trusted-server-core integrations::gpt`
+ Expected: all pass including new test
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add crates/trusted-server-core/src/integrations/gpt.rs
+ git commit -m "Emit __tsAdInit function definition from GPT head injector"
+ ```
+
+---
+
+## Task 10: `gpt/index.ts` — TypeScript `__tsAdInit`
+
+**Files:**
+
+- Modify: `crates/js/lib/src/integrations/gpt/index.ts`
+
+The TypeScript version is the authoritative implementation; it must mirror the Rust inline string from Task 9 exactly.
+
+- [ ] **Step 1: Write a failing test**
+
+ In `crates/js/lib/src/integrations/gpt/index.test.ts`:
+
+ ```typescript
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+ describe('installTsAdInit', () => {
+ beforeEach(() => {
+ delete (window as any).__ts_ad_slots
+ delete (window as any).__ts_bids
+ delete (window as any).__tsAdInit
+ })
+
+ it('defines googletag slots from __ts_ad_slots and calls refresh', () => {
+ const mockSlot = {
+ addService: vi.fn().mockReturnThis(),
+ setTargeting: vi.fn().mockReturnThis(),
+ }
+ const mockPubads = {
+ enableSingleRequest: vi.fn(),
+ addEventListener: vi.fn(),
+ refresh: vi.fn(),
+ getTargeting: vi.fn().mockReturnValue([]),
+ }
+ ;(window as any).googletag = {
+ cmd: { push: vi.fn((fn: () => void) => fn()) },
+ defineSlot: vi.fn().mockReturnValue(mockSlot),
+ pubads: vi.fn().mockReturnValue(mockPubads),
+ enableServices: vi.fn(),
+ }
+ ;(window as any).__ts_ad_slots = [
+ {
+ id: 'atf',
+ gam_unit_path: '/123/atf',
+ div_id: 'atf',
+ formats: [[300, 250]],
+ targeting: { pos: 'atf' },
+ },
+ ]
+ ;(window as any).__ts_bids = {
+ atf: {
+ hb_pb: '1.00',
+ hb_bidder: 'kargo',
+ hb_adid: 'abc',
+ burl: 'https://ssp/bill',
+ },
+ }
+
+ // Must import installTsAdInit from the module
+ const { installTsAdInit } = require('./index')
+ installTsAdInit()
+ ;(window as any).__tsAdInit()
+
+ expect((window as any).googletag.defineSlot).toHaveBeenCalledWith(
+ '/123/atf',
+ [[300, 250]],
+ 'atf'
+ )
+ expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00')
+ expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo')
+ expect(mockPubads.refresh).toHaveBeenCalled()
+ })
+
+ it('fires burl via sendBeacon on slotRenderEnded when our bid won', () => {
+ const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true)
+ // ... setup and trigger slotRenderEnded event
+ // Verify: navigator.sendBeacon called with burl
+ beaconSpy.mockRestore()
+ })
+ })
+ ```
+
+ Run: `cd crates/js/lib && npx vitest run`
+ Expected: FAIL — `installTsAdInit` not exported
+
+- [ ] **Step 2: Add `installTsAdInit` to `index.ts`**
+
+ Add to `crates/js/lib/src/integrations/gpt/index.ts` (bottom of file):
+
+ ```typescript
+ interface TsAdSlot {
+ id: string
+ gam_unit_path: string
+ div_id: string
+ formats: Array
+ targeting: Record
+ }
+
+ interface TsBidData {
+ hb_pb?: string
+ hb_bidder?: string
+ hb_adid?: string
+ burl?: string
+ }
+
+ type TsWindow = Window & {
+ __ts_ad_slots?: TsAdSlot[]
+ __ts_bids?: Record
+ __tsAdInit?: () => void
+ }
+
+ /**
+ * Install `window.__tsAdInit` — reads `window.__ts_ad_slots` and `window.__ts_bids`
+ * (injected by the edge into ), defines GPT slots, applies pre-won bid targeting,
+ * registers a `slotRenderEnded` listener to fire `burl` via `sendBeacon`, then calls
+ * `refresh()`.
+ */
+ export function installTsAdInit(): void {
+ const w = window as TsWindow
+ w.__tsAdInit = function () {
+ const slots = w.__ts_ad_slots ?? []
+ const bids = w.__ts_bids ?? {}
+ const g = (window as GptWindow).googletag
+ if (!g) return
+ g.cmd.push(() => {
+ slots.forEach((slot) => {
+ const gptSlot = g.defineSlot?.(
+ slot.gam_unit_path,
+ slot.formats,
+ slot.div_id
+ )
+ if (!gptSlot) return
+ gptSlot.addService(g.pubads())
+ Object.entries(slot.targeting ?? {}).forEach(([k, v]) =>
+ gptSlot.setTargeting(k, v)
+ )
+ const bid = bids[slot.id] ?? {}
+ ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => {
+ if (bid[key]) gptSlot.setTargeting(key, bid[key]!)
+ })
+ })
+ g.pubads().enableSingleRequest()
+ g.enableServices()
+ g.pubads().addEventListener?.('slotRenderEnded', (event: any) => {
+ const slotId: string = event.slot?.getSlotElementId?.() ?? ''
+ const bid = bids[slotId] ?? {}
+ if (
+ !event.isEmpty &&
+ bid.burl &&
+ event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid
+ ) {
+ navigator.sendBeacon(bid.burl)
+ }
+ })
+ g.pubads().refresh()
+ })
+ }
+ }
+ ```
+
+ Call `installTsAdInit()` from the integration's initialization path so it's set up when the bundle loads.
+
+- [ ] **Step 3: Run JS tests**
+
+ Run: `cd crates/js/lib && npx vitest run`
+ Expected: new tests pass
+
+- [ ] **Step 4: Build JS bundle**
+
+ Run: `cd crates/js/lib && node build-all.mjs`
+ Expected: clean build
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add crates/js/lib/src/integrations/gpt/
+ git commit -m "Add __tsAdInit and slotRenderEnded burl firing to GPT integration"
+ ```
+
+---
+
+## Task 11: `nurl` fire-and-forget
+
+**Files:**
+
+- Modify: `crates/trusted-server-core/src/integrations/prebid.rs`
+- Modify: `crates/trusted-server-core/src/publisher.rs`
+
+- [ ] **Step 1: Write failing test**
+
+ ```rust
+ #[test]
+ fn prebid_config_fire_nurl_defaults_to_true() {
+ let config = PrebidConfig::default();
+ assert!(config.fire_nurl_at_edge, "should fire nurl at edge by default");
+ }
+ ```
+
+ Run: `cargo test -p trusted-server-core integrations::prebid`
+ Expected: FAIL
+
+- [ ] **Step 2: Add `fire_nurl_at_edge` to `PrebidConfig`**
+
+ ```rust
+ #[serde(default = "default_fire_nurl_at_edge")]
+ pub fire_nurl_at_edge: bool,
+ ```
+
+ ```rust
+ fn default_fire_nurl_at_edge() -> bool { true }
+ ```
+
+- [ ] **Step 3: Fire nurls in publisher.rs after auction**
+
+ After `auction_result` is obtained, add:
+
+ ```rust
+ if let Some(ref result) = auction_result {
+ fire_winning_nurls(result, settings);
+ }
+ ```
+
+ Add helper (no `.await` — fire-and-forget):
+
+ ```rust
+ fn fire_winning_nurls(
+ result: &crate::auction::orchestrator::OrchestrationResult,
+ settings: &Settings,
+ ) {
+ use crate::backend::BackendConfig;
+
+ let fire_nurl = settings
+ .integrations
+ .get_typed::("prebid")
+ .map(|c| c.fire_nurl_at_edge)
+ .unwrap_or(true);
+
+ if !fire_nurl {
+ return;
+ }
+
+ for bid in result.winning_bids.values() {
+ let Some(ref nurl) = bid.nurl else { continue };
+ let backend_name = match BackendConfig::from_url(nurl, false) {
+ Ok(name) => name,
+ Err(e) => {
+ log::warn!("nurl: cannot create backend for {nurl}: {e:?}");
+ continue;
+ }
+ };
+ match fastly::Request::get(nurl).send_async(&backend_name) {
+ Ok(_) => log::debug!("nurl: fired for slot {}", bid.slot_id),
+ Err(e) => log::warn!("nurl: failed for slot {}: {e}", bid.slot_id),
+ }
+ }
+ }
+ ```
+
+- [ ] **Step 4: Run tests**
+
+ Run: `cargo test --workspace`
+ Expected: all pass
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add crates/trusted-server-core/src/integrations/prebid.rs \
+ crates/trusted-server-core/src/publisher.rs
+ git commit -m "Fire winning bid nurl fire-and-forget from edge; add fire_nurl_at_edge config"
+ ```
+
+---
+
+## Task 12: End-to-end integration tests
+
+**Files:**
+
+- Modify: `crates/trusted-server-core/src/publisher.rs` (test module)
+
+Tests use `pub(crate)` helpers from Task 8 directly.
+
+- [ ] **Step 1: Write tests**
+
+ In `publisher.rs` test module:
+
+ ```rust
+ #[cfg(test)]
+ mod creative_opportunities_tests {
+ use super::{build_ad_slots_script, build_ad_bids_script, html_escape_for_script};
+ use crate::creative_opportunities::{
+ CreativeOpportunitiesConfig, CreativeOpportunitySlot, CreativeOpportunityFormat,
+ CreativeOpportunitiesFile, match_slots,
+ };
+ use crate::auction::types::{Bid, MediaType};
+ use crate::price_bucket::PriceGranularity;
+ use std::collections::HashMap;
+
+ fn make_config() -> CreativeOpportunitiesConfig {
+ CreativeOpportunitiesConfig {
+ gam_network_id: "21765378893".to_string(),
+ auction_timeout_ms: Some(500),
+ price_granularity: PriceGranularity::Dense,
+ }
+ }
+
+ fn make_slot() -> CreativeOpportunitySlot {
+ CreativeOpportunitySlot {
+ id: "atf_sidebar_ad".to_string(),
+ gam_unit_path: Some("/21765378893/publisher/atf-sidebar".to_string()),
+ div_id: Some("div-atf-sidebar".to_string()),
+ page_patterns: vec!["/20**".to_string()],
+ formats: vec![CreativeOpportunityFormat {
+ width: 300, height: 250, media_type: MediaType::Banner,
+ }],
+ floor_price: Some(0.50),
+ targeting: [("pos".to_string(), "atf".to_string())].into_iter().collect(),
+ providers: Default::default(),
+ }
+ }
+
+ #[test]
+ fn ad_slots_script_is_safe_and_parseable() {
+ let slots = vec![make_slot()];
+ let config = make_config();
+ let script = build_ad_slots_script(&slots, &config);
+ assert!(script.contains("window.__ts_ad_slots=JSON.parse"), "should use JSON.parse");
+ assert!(script.contains("atf_sidebar_ad"), "should include slot id");
+ // Verify no raw < or > that could break HTML parser
+ let inner = script.trim_start_matches("");
+ assert!(!inner.contains('<'), "no unescaped < in script content");
+ assert!(!inner.contains('>'), "no unescaped > in script content");
+ }
+
+ #[test]
+ fn ad_bids_script_uses_price_bucket_and_ad_id() {
+ let mut winning_bids = HashMap::new();
+ winning_bids.insert("atf_sidebar_ad".to_string(), Bid {
+ slot_id: "atf_sidebar_ad".to_string(),
+ price: Some(2.53),
+ currency: "USD".to_string(),
+ creative: None,
+ adomain: None,
+ bidder: "kargo".to_string(),
+ width: 300, height: 250,
+ nurl: None,
+ burl: Some("https://ssp.example/billing?id=abc123".to_string()),
+ ad_id: Some("prebid-uuid-abc123".to_string()),
+ metadata: HashMap::new(),
+ });
+ let script = build_ad_bids_script(&winning_bids, PriceGranularity::Dense);
+ assert!(script.contains("\"hb_pb\":\"2.53\""), "should bucket 2.53 as 2.53 (dense)");
+ assert!(script.contains("\"hb_bidder\":\"kargo\""), "should include bidder");
+ assert!(script.contains("\"hb_adid\":\"prebid-uuid-abc123\""), "should use ad_id not creative markup");
+ assert!(script.contains("burl"), "should include burl for billing");
+ }
+
+ #[test]
+ fn html_escape_neutralizes_xss_in_json() {
+ let malicious = r#"{"zone":""), "should escape ");
+ assert!(escaped.contains("\\u003c"), "should unicode-escape <");
+ assert!(escaped.contains("\\u003e"), "should unicode-escape >");
+ }
+
+ #[test]
+ fn url_matching_end_to_end() {
+ let file = CreativeOpportunitiesFile { slots: vec![make_slot()] };
+ assert_eq!(match_slots(&file.slots, "/2024/01/my-article").len(), 1, "should match article");
+ assert_eq!(match_slots(&file.slots, "/about").len(), 0, "should not match /about");
+ assert_eq!(match_slots(&file.slots, "/").len(), 0, "should not match root");
+ }
+ }
+ ```
+
+- [ ] **Step 2: Run tests**
+
+ Run: `cargo test -p trusted-server-core creative_opportunities_tests`
+ Expected: all pass
+
+- [ ] **Step 3: Run full suite + CI gates**
+
+ ```bash
+ cargo test --workspace
+ cargo clippy --workspace --all-targets --all-features -- -D warnings
+ cargo fmt --all -- --check
+ cd crates/js/lib && npx vitest run
+ cd crates/js/lib && npm run format
+ cd docs && npm run format
+ ```
+
+ Expected: all clean
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add crates/trusted-server-core/src/publisher.rs
+ git commit -m "Add integration tests for creative opportunities pipeline (slots, bids, XSS)"
+ ```
+
+---
+
+## Manual Verification Checklist
+
+Run `fastly compute serve` and verify:
+
+- [ ] **No match:** Request `/about` — no `__ts_ad_slots` or `__ts_bids` in response HTML, no `Cache-Control: private, no-store`
+- [ ] **Match:** Request `/2024/01/article` — both globals present in ``, `Cache-Control: private, no-store` set
+- [ ] **Empty file kill-switch:** Empty `creative-opportunities.toml` → no globals injected on any URL
+- [ ] **Auction timeout:** Set `auction_timeout_ms = 1` → `__ts_bids` injects as `{}`, no slot entries
+- [ ] **XSS check:** Add `targeting = { zone = "
+```
+
+> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped
+> before insertion into the ``, not
-> raw string interpolation.
+- If the auction has already completed for ``, response returns immediately
+ with cached results (cache hit). Typical case for non-trivial origin times.
+- If the auction is still in flight, the request blocks until completion or `A_deadline`,
+ whichever fires first. Long-poll semantics, capped by the auction timeout.
+- If `` is unknown (cache miss, expired TTL, or never created), returns
+ `404`. Client falls back to firing GPT without pre-set targeting.
+- If no slot received a bid above floor, returns `{}`. Client fires GPT without targeting.
+- Response carries `Cache-Control: private, no-store`.
-> **Cache contract:** Any response with `__ts_bids` injected is per-user data and must
-> not be cached. TS sets `Cache-Control: private, no-store` on the response before
-> forwarding, overriding any conflicting cache headers from the publisher origin.
-> `Surrogate-Control` and `Fastly-Surrogate-Control` are also stripped.
+**Storage:** auction results cached in-process (per-edge-instance) keyed by request ID
+with a 30-second TTL. Sized small (a few KB per entry) and short-lived; no Fastly KV
+write on the hot path.
+
+**Security:** request IDs are 128-bit unguessable UUIDs. Even if a request ID leaks, the
+worst-case impact is reading bid metadata that's already destined for that session's
+GPT slots — no cross-user data exposure.
### 4.5 Win Notifications
@@ -386,119 +455,357 @@ Prebid bid (not a direct deal or backfill) won the GAM line item match.
### 4.6 Client Residual
Prebid.js is eliminated. The client-side ad bootstrap is replaced by a small inline
-script (~30 lines) that reads `__ts_ad_slots` and `__ts_bids`, drives GPT directly, and
-handles billing notifications:
+script that reads `__ts_ad_slots`, fetches bids from `/ts-bids`, drives GPT directly,
+and handles billing notifications. Slot definition happens immediately; bid targeting
+and `refresh()` happen after `/ts-bids` resolves:
```javascript
window.__tsAdInit = function () {
var slots = window.__ts_ad_slots || []
- var bids = window.__ts_bids || {}
+ var rid = window.__ts_request_id
+
+ // Kick off bid fetch as early as possible. Fires in parallel with GPT setup.
+ var bidsPromise = rid
+ ? fetch('/ts-bids?rid=' + encodeURIComponent(rid), { credentials: 'omit' })
+ .then(function (r) {
+ return r.ok ? r.json() : {}
+ })
+ .catch(function () {
+ return {}
+ })
+ : Promise.resolve({})
+
googletag.cmd.push(function () {
- slots.forEach(function (slot) {
+ // Define slots immediately — no auction wait
+ var gptSlots = slots.map(function (slot) {
var gptSlot = googletag
.defineSlot(slot.gam_unit_path, slot.formats, slot.div_id)
.addService(googletag.pubads())
- // Apply static targeting from config
Object.entries(slot.targeting).forEach(function ([k, v]) {
gptSlot.setTargeting(k, v)
})
- // Apply pre-won bid targeting if available
- var bidData = bids[slot.id] || {}
- ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (key) {
- if (bidData[key]) gptSlot.setTargeting(key, bidData[key])
- })
+ return { id: slot.id, gptSlot: gptSlot }
})
+
googletag.pubads().enableSingleRequest()
googletag.enableServices()
- // Fire burl on confirmed render
- googletag.pubads().addEventListener('slotRenderEnded', function (event) {
- var slotId = event.slot.getSlotElementId()
- var bidData = bids[slotId] || {}
- if (
- !event.isEmpty &&
- bidData.burl &&
- event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid
- ) {
- navigator.sendBeacon(bidData.burl)
- }
+
+ // Apply bid targeting and refresh once /ts-bids resolves.
+ bidsPromise.then(function (bids) {
+ gptSlots.forEach(function ({ id, gptSlot }) {
+ var bidData = bids[id] || {}
+ ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (key) {
+ if (bidData[key]) gptSlot.setTargeting(key, bidData[key])
+ })
+ })
+
+ // Fire burl on confirmed render
+ googletag.pubads().addEventListener('slotRenderEnded', function (event) {
+ var slotId = event.slot.getSlotElementId()
+ var bidData = bids[slotId] || {}
+ if (
+ !event.isEmpty &&
+ bidData.burl &&
+ event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid
+ ) {
+ navigator.sendBeacon(bidData.burl)
+ }
+ })
+
+ googletag.pubads().refresh()
})
- googletag.pubads().refresh()
})
}
```
+**Why slot definition happens before bid fetch resolves:** GPT slot definition is
+synchronous and cheap. Defining slots early lets GPT prepare iframes and start any
+internal work that doesn't require ad server response. `refresh()` is the call that
+actually triggers the GAM ad request — that's the one we delay until bids arrive.
+
+**Failure modes:**
+
+- `/ts-bids` returns 404 (unknown rid, TTL expired) → `bidsPromise` resolves to `{}`,
+ `refresh()` fires without bid targeting, GAM falls back to its own auction. Same
+ graceful degradation as no-bid case.
+- `/ts-bids` network failure → caught, resolves to `{}`, same fallback.
+- Auction times out server-side → `/ts-bids` returns `{}`, same fallback.
+
This script is part of the existing `gpt` integration bundle
(`crates/js/lib/src/integrations/gpt/index.ts`), extending the existing GPT shim.
Injected via the `gpt` head injector alongside `window.__ts_ad_slots`.
+### 4.7 Caching Behavior
+
+Page assets and bid results have very different cacheability properties. The
+architecture is designed so that everything that can be cached, is.
+
+**What gets cached where:**
+
+| Asset | Cached at | Cacheability |
+| ------------------------ | -------------------------------- | --------------------------------------------------------- |
+| Origin HTML | Fastly edge HTTP cache | Yes, if origin sends `Cache-Control: public, max-age=...` |
+| Origin CSS / fonts / JS | Fastly edge + browser | Yes (typically hashed URLs, immutable) |
+| `tsjs` bundle | Fastly edge + browser | Yes (already content-hashed via `bundle.rs`, immutable) |
+| `__ts_ad_slots` payload | Could be precomputed per pattern | In-memory match is sub-millisecond — not worth caching |
+| `__ts_request_id` | **Never** | Per-request UUID, minted at request receipt |
+| Bid results (`/ts-bids`) | In-process `bid_cache`, 30s TTL | Per-request, never shared across users |
+
+**Architecture:**
+
+1. Fastly's built-in HTTP cache stores the **origin response** keyed by URL. TS
+ does not implement its own HTML caching layer — it leverages the existing
+ Fastly cache.
+2. On request: TS reads from cache (cache hit, ~5ms) or fetches from origin
+ (cache miss, ~150ms typical).
+3. TS injects `__ts_ad_slots` + `__ts_request_id` at the `` open via the
+ existing `el.prepend()` head handler. This injection is per-request — origin
+ HTML in cache is unmodified.
+4. TS forces `Transfer-Encoding: chunked` and streams the assembled response
+ to the browser.
+5. The auction runs in parallel regardless of HTML cache state — bids land in
+ `bid_cache` keyed by `request_id`, served via `/ts-bids` when the client
+ fetches.
+
+The `bid_cache` (per-request bid results) and Fastly's HTML cache are
+**independent systems**. HTML cache hit/miss does not affect auction firing;
+auction firing does not affect HTML caching.
+
+**`Cache-Control` handling:**
+
+TS preserves the origin's `Cache-Control` header on the response sent to the
+browser, with one override: when `__ts_request_id` is injected (any matched
+page), TS sets `Cache-Control: private, no-store` on the **browser-facing**
+response to prevent intermediate caches or the browser from caching the
+per-user assembled HTML. The Fastly edge cache for the **origin** response is
+unaffected — TS reads the cached origin HTML and assembles a fresh per-request
+response on every hit.
+
+`Surrogate-Control` and `Fastly-Surrogate-Control` headers from origin are
+preserved (they control Fastly's cache, not the browser's).
+
+**When caching doesn't apply:**
+
+- **Logged-in users** — origin typically returns `Cache-Control: private`. Falls
+ back to cache-miss timing (full origin fetch).
+- **Personalized SSR** (per-user content, A/B test variants) — same.
+- **Dynamic NextJS routes without ISR** — origin sends `Cache-Control: no-store`
+ or short max-age. Falls back to cache-miss timing.
+- **First request after deploy or cache purge** — cold cache, full origin fetch.
+- **Long-tail URLs** — low cache hit rate, treat as cache-miss case.
+
+For typical news / content publisher sites with anonymous visitors on stable
+content pages, expect 70–90%+ edge cache hit rate. The cache-hit timing in §5
+is the realistic common case, not the optimistic best case.
+
---
## 5. Request-Time Sequence
+Sequence applies to all origins (WordPress, Drupal, Rails, NextJS 14/16, static sites).
+TS forces chunked encoding on every response, so origin format is invisible from the
+browser's perspective.
+
+### 5.1 Visual Sequence (full content + creative flow)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant B as Browser
+ participant E as TS Edge
(Fastly)
+ participant C as Fastly HTTP Cache
+ participant O as Publisher Origin
(WP / NextJS / etc)
+ participant A as Auction
(PBS + APS)
+ participant S as SSPs
(Kargo / Index / etc)
+ participant G as GAM
(securepubads)
+
+ Note over B,G: t=0ms — Navigation start
+
+ B->>E: GET ts.publisher.com/article
+
+ Note over E: t=1ms — URL → slots match
Mint request_id (UUID)
Check consent
+
+ par Auction kicks off server-side
+ E->>A: POST bid requests
(PBS + APS in parallel)
+ A->>S: Fan out to all SSPs
+ S-->>A: Bids return
+ A-->>E: Aggregated bid responses
(t=502ms)
+ Note over E: Cache bids in bid_cache
(keyed by request_id, 30s TTL)
+ E->>S: Fire nurl (fire-and-forget)
for winning bids
+ and Origin HTML lookup
+ E->>C: Lookup origin HTML by URL
+ alt Cache HIT (typical for content pages)
+ C-->>E: Cached HTML (~5ms)
+ else Cache MISS (cold / dynamic / logged-in)
+ C->>O: GET origin HTML
+ O-->>C: HTML response (~150ms)
+ C-->>E: HTML response
+ end
+ end
+
+ Note over E: Force Transfer-Encoding: chunked
Inject __ts_ad_slots + __ts_request_id
at open
Set Cache-Control: private, no-store
+
+ E-->>B: Stream HTML chunks (no auction wait)
+
+ Note over B: TTFB: ~10ms (hit) / ~155ms (miss)
Browser parses
CSS, fonts, tsjs download
(also from Fastly + browser cache)
+
+ Note over B: flushes immediately
Body parsing begins
🎨 FCP: ~80ms (hit) / ~250ms (miss)
+
+ Note over B: tsjs bundle executes
t=130ms (hit) / t=300ms (miss)
__tsAdInit() defines GPT slots
(no GAM call yet)
+
+ B->>E: GET /ts-bids?rid=
+
+ alt Auction already complete (typical on cache-hit pages)
+ Note over E: bid_cache hit — return immediately
+ E-->>B: Bid targeting JSON
(hb_pb, hb_bidder, hb_adid, burl)
+ else Auction still running
+ Note over E: Long-poll — block until
auction completes or A_deadline
+ A-->>E: Bids arrive
+ E-->>B: Bid targeting JSON
(or {} on timeout)
+ end
+
+ Note over B: Bids received (~30ms RTT)
setTargeting(hb_*) per slot
Register slotRenderEnded listener
googletag.pubads().refresh() fires
+
+ B->>G: GET /gampad/ads
with hb_* key-values
+
+ Note over G: GAM matches hb_pb against
Prebid line items, selects winner
+
+ G-->>B: Ad markup
(iframe HTML or creative URL)
+
+ Note over B: Creative iframe loads in slot
Fetches sub-resources
(images, scripts, viewability pixels)
+
+ Note over B: 🎯 Creative paints
slotRenderEnded event fires
__tsAdInit checks hb_adid match
+
+ alt Our Prebid bid won the GAM line item match
+ B->>S: Fire burl (navigator.sendBeacon)
SSP confirms billable impression
+ else Direct deal / backfill won (hb_adid mismatch or empty)
+ Note over B: No burl fired — our bid lost
(correct behavior — different creative rendered)
+ end
+
+ Note over B: window.load fires
(page fully loaded)
+
+ Note over B,G: ✅ AD VISIBLE
Cache hit: ~900ms total
Cache miss: ~1,050ms total
FCP: ~80ms (hit) / ~250ms (miss)
vs client-side today: ~3,250ms ad-visible / FCP ~500ms+
+```
+
+### 5.2 Cache-Hit Sequence (typical for content publisher pages)
+
+This is the common case for anonymous visitors on cacheable content pages.
+
```
t=0ms GET ts.publisher.com/article arrives at Fastly edge
t=1ms URL matched against creative-opportunities.toml
Slots matched: [atf_sidebar_ad, below-content-ad, section_ad]
Consent check: TCF consent present → auction proceeds
+ Request ID minted: 550e8400-e29b-41d4-a716-446655440000
-t=2ms AuctionOrchestrator.run_auction() called
+t=2ms AuctionOrchestrator.run_auction() dispatched (parallel)
PBS + APS dispatched in parallel via send_async()
Edge→PBS RTT: ~20–30ms
+ Fastly cache lookup dispatched in parallel
+ __ts_ad_slots + __ts_request_id ".to_string()
+ r#""#
+ .to_string()
),
- ad_bids_script: None,
};
let mut processor = create_html_processor(config);
let output = processor
.process_chunk(b"T", true)
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
- assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots");
+ assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots at head-open");
+ assert!(html.contains("window.__ts_request_id"), "should inject request_id at head-open");
}
#[test]
- fn injects_bids_before_end_of_head() {
- let bids_script = "";
+ fn does_not_hold_end_of_head() {
+ // Verify: no bid data appears before — that hold was rejected by spec §4.3
let config = HtmlProcessorConfig {
origin_host: "origin.example.com".to_string(),
request_host: "example.com".to_string(),
request_scheme: "https".to_string(),
integrations: IntegrationRegistry::empty_for_tests(),
ad_slots_script: None,
- ad_bids_script: Some(bids_script.to_string()),
};
let mut processor = create_html_processor(config);
let output = processor
.process_chunk(b"T", true)
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
- assert!(html.contains("window.__ts_bids"), "should inject bids");
- let bids_pos = html.find("window.__ts_bids").expect("should find bids");
- let end_head_pos = html.find("").expect("should find ");
- assert!(bids_pos < end_head_pos, "bids script should appear before ");
+ assert!(!html.contains("__ts_bids"), "must not inject bids into head");
}
```
Run: `cargo test -p trusted-server-core html_processor`
- Expected: compile error (no `ad_slots_script`/`ad_bids_script` fields, no `empty_for_tests()`)
+ Expected: compile error (no `ad_slots_script` field, no `empty_for_tests()`)
- [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`**
@@ -888,7 +886,6 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in
#[cfg(test)]
impl IntegrationRegistry {
pub fn empty_for_tests() -> Self {
- // Minimal registry with no integrations for unit testing html_processor
Self {
inner: Arc::new(RegistryInner {
proxies: Default::default(),
@@ -905,7 +902,9 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in
(Adjust field names to match the actual `RegistryInner` struct.)
-- [ ] **Step 3: Add fields to `HtmlProcessorConfig`**
+- [ ] **Step 3: Add single field to `HtmlProcessorConfig`**
+
+ Replace any existing `ad_slots_script`/`ad_bids_script` fields with:
```rust
pub struct HtmlProcessorConfig {
@@ -913,56 +912,47 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in
pub request_host: String,
pub request_scheme: String,
pub integrations: IntegrationRegistry,
- /// Pre-computed `` for matched slots.
- /// Injected at open, before integration head inserts. `None` when no slots matched.
+ /// Pre-computed ``.
+ /// Injected at `` open, before integration head inserts. `None` when no slots matched.
pub ad_slots_script: Option,
- /// Pre-computed `` for winning bids.
- /// Injected immediately before via on_end_tag(). `None` when auction not run.
- pub ad_bids_script: Option,
}
```
- Update `from_settings` to initialize `ad_slots_script: None, ad_bids_script: None`.
+ Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_slots_script: None`.
-- [ ] **Step 4: Inject `__ts_ad_slots` at head-open AND register `on_end_tag` for `__ts_bids`**
+- [ ] **Step 4: Inject `ad_slots_script` at head-open**
- In `create_html_processor`, within the EXISTING single `element!("head", ...)` handler, make two changes:
- 1. Prepend the ad slots script BEFORE the existing integration inserts:
+ In `create_html_processor`, within the EXISTING `element!("head", ...)` handler, build the full snippet string with `ad_slots_script` first (so it appears first in output — lol_html `prepend` inserts before children, with **last-prepend-wins** ordering, so we call `prepend` exactly once with the full combined string):
- ```rust
- // NEW: inject __ts_ad_slots first
- if let Some(ref slots_script) = ad_slots_script {
- snippet.push_str(slots_script);
- }
- // ... existing: for insert in integrations.head_inserts(&ctx) { ... }
- ```
+ ```rust
+ let ad_slots_script = config.ad_slots_script.clone();
+ // ... existing captures ...
- 2. After `el.prepend(...)`, register the end-tag handler for `__ts_bids`:
- ```rust
- // Register on_end_tag handler for __ts_bids injection before
- if let Some(bids_script) = ad_bids_script.clone() {
- el.on_end_tag(move |end_tag| {
- end_tag.before(&bids_script, ContentType::Html);
- Ok(())
- })?;
- }
- ```
+ element!("head", |el| {
+ let mut snippet = String::new();
- Both changes live inside the same `element!("head", ...)` closure — no second handler needed.
+ // ad_slots_script first so __ts_ad_slots + __ts_request_id appear before
+ // integration inserts. DO NOT call prepend multiple times — lol_html stacks
+ // prepend calls in reverse order, so a single prepend with the full string
+ // guarantees correct ordering.
+ if let Some(ref slots_script) = ad_slots_script {
+ snippet.push_str(slots_script);
+ }
- Capture `ad_slots_script` and `ad_bids_script` into the closure the same way as `injected_tsjs`:
+ // ... existing: for insert in integrations.head_inserts(&ctx) { snippet.push_str(...) }
- ```rust
- let ad_slots_script = config.ad_slots_script.clone();
- let ad_bids_script = config.ad_bids_script.clone();
+ if !snippet.is_empty() {
+ el.prepend(&snippet, ContentType::Html);
+ }
+ // DO NOT register on_end_tag — flushes immediately per spec §4.3
+ Ok(())
+ })
```
- > **lol_html `on_end_tag` API note:** `Element::on_end_tag(handler)` is available in lol_html ≥2.0. The handler receives `&mut EndTag` and must return `Result<(), Box>`. Use `ContentType::Html` so the injected `", escaped)
+ let slots_json_str = serde_json::to_string(&slots_json)
+ .expect("should serialize ad slots");
+ let escaped_slots = html_escape_for_script(&slots_json_str);
+ // request_id is a UUID (hex + hyphens only) — safe to embed without escaping.
+ format!(
+ r#""#
+ )
}
- pub(crate) fn build_ad_bids_script(
+ /// Build the `BidMap` stored in `bid_cache` and returned by `/ts-bids`.
+ ///
+ /// Keyed by slot ID. Values contain `hb_pb`, `hb_bidder`, `hb_adid`, `burl`.
+ pub(crate) fn build_bid_map(
winning_bids: &std::collections::HashMap,
price_granularity: crate::price_bucket::PriceGranularity,
- ) -> String {
- let bids_map: serde_json::Map = winning_bids
+ ) -> crate::bid_cache::BidMap {
+ winning_bids
.iter()
.filter_map(|(slot_id, bid)| {
let cpm = bid.price?;
- let entry = serde_json::json!({
- "hb_pb": price_bucket(cpm, price_granularity),
- "hb_bidder": bid.bidder,
- "hb_adid": bid.ad_id.as_deref().unwrap_or(""),
- "burl": bid.burl,
- });
- Some((slot_id.clone(), entry))
+ let entry: std::collections::HashMap = [
+ ("hb_pb".to_string(), serde_json::Value::String(price_bucket(cpm, price_granularity))),
+ ("hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone())),
+ ("hb_adid".to_string(), serde_json::Value::String(
+ bid.ad_id.as_deref().unwrap_or("").to_string()
+ )),
+ ("burl".to_string(), bid.burl.as_deref()
+ .map(serde_json::Value::from)
+ .unwrap_or(serde_json::Value::Null)),
+ ].into_iter().collect();
+ Some((slot_id.clone(), entry.into_iter()
+ .map(|(k, v)| (k, v))
+ .collect::>()
+ .into()))
})
- .collect();
- let json = serde_json::to_string(&serde_json::Value::Object(bids_map))
- .expect("should serialize bids");
- let escaped = html_escape_for_script(&json);
- format!("", escaped)
+ .collect()
}
/// HTML-escape a JSON string for safe inline `"
.to_string(),
- // __tsAdInit definition — reads window.__ts_ad_slots / __ts_bids at call time.
+ // __tsAdInit: fetches /ts-bids for bid targeting, then drives GPT.
+ // window.__ts_ad_slots and window.__ts_request_id are injected at head-open by TS.
+ // bidsPromise resolves concurrently with page rendering — never blocks FCP.
concat!(
""
@@ -1394,20 +1825,20 @@ The `HtmlProcessorConfig` fields now exist (Task 7). This task wires the auction
```bash
git add crates/trusted-server-core/src/integrations/gpt.rs
- git commit -m "Emit __tsAdInit function definition from GPT head injector"
+ git commit -m "Emit __tsAdInit with /ts-bids fetch pattern from GPT head injector"
```
---
-## Task 10: `gpt/index.ts` — TypeScript `__tsAdInit`
+## Task 12: `gpt/index.ts` — TypeScript `__tsAdInit` with `/ts-bids` fetch
**Files:**
- Modify: `crates/js/lib/src/integrations/gpt/index.ts`
-The TypeScript version is the authoritative implementation; it must mirror the Rust inline string from Task 9 exactly.
+The TypeScript version mirrors the Rust inline string from Task 11. It uses the `bidsPromise` pattern — fetching `/ts-bids` concurrently with GPT slot definition.
-- [ ] **Step 1: Write a failing test**
+- [ ] **Step 1: Write failing tests**
In `crates/js/lib/src/integrations/gpt/index.test.ts`:
@@ -1417,20 +1848,21 @@ The TypeScript version is the authoritative implementation; it must mirror the R
describe('installTsAdInit', () => {
beforeEach(() => {
delete (window as any).__ts_ad_slots
- delete (window as any).__ts_bids
+ delete (window as any).__ts_request_id
delete (window as any).__tsAdInit
})
- it('defines googletag slots from __ts_ad_slots and calls refresh', () => {
+ it('fetches /ts-bids with request_id and applies bid targeting before refresh', async () => {
const mockSlot = {
addService: vi.fn().mockReturnThis(),
setTargeting: vi.fn().mockReturnThis(),
+ getSlotElementId: vi.fn().mockReturnValue('atf'),
+ getTargeting: vi.fn().mockReturnValue([]),
}
const mockPubads = {
enableSingleRequest: vi.fn(),
addEventListener: vi.fn(),
refresh: vi.fn(),
- getTargeting: vi.fn().mockReturnValue([]),
}
;(window as any).googletag = {
cmd: { push: vi.fn((fn: () => void) => fn()) },
@@ -1447,45 +1879,131 @@ The TypeScript version is the authoritative implementation; it must mirror the R
targeting: { pos: 'atf' },
},
]
- ;(window as any).__ts_bids = {
- atf: {
- hb_pb: '1.00',
- hb_bidder: 'kargo',
- hb_adid: 'abc',
- burl: 'https://ssp/bill',
- },
- }
+ ;(window as any).__ts_request_id = 'test-rid-123'
+
+ const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ atf: {
+ hb_pb: '1.00',
+ hb_bidder: 'kargo',
+ hb_adid: 'abc',
+ burl: 'https://ssp/bill',
+ },
+ }),
+ } as Response)
- // Must import installTsAdInit from the module
- const { installTsAdInit } = require('./index')
+ const { installTsAdInit } = await import('./index')
installTsAdInit()
- ;(window as any).__tsAdInit()
+ await (window as any).__tsAdInit()
- expect((window as any).googletag.defineSlot).toHaveBeenCalledWith(
- '/123/atf',
- [[300, 250]],
- 'atf'
+ expect(fetchSpy).toHaveBeenCalledWith(
+ expect.stringContaining('/ts-bids?rid=test-rid-123'),
+ expect.objectContaining({ credentials: 'omit' })
)
expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00')
expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo')
expect(mockPubads.refresh).toHaveBeenCalled()
+
+ fetchSpy.mockRestore()
+ })
+
+ it('calls refresh with empty bids when fetch fails', async () => {
+ const mockPubads = {
+ enableSingleRequest: vi.fn(),
+ addEventListener: vi.fn(),
+ refresh: vi.fn(),
+ }
+ ;(window as any).googletag = {
+ cmd: { push: vi.fn((fn: () => void) => fn()) },
+ defineSlot: vi.fn().mockReturnValue({
+ addService: vi.fn().mockReturnThis(),
+ setTargeting: vi.fn().mockReturnThis(),
+ }),
+ pubads: vi.fn().mockReturnValue(mockPubads),
+ enableServices: vi.fn(),
+ }
+ ;(window as any).__ts_ad_slots = []
+ ;(window as any).__ts_request_id = 'rid-fail'
+
+ vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error'))
+
+ const { installTsAdInit } = await import('./index')
+ installTsAdInit()
+ await (window as any).__tsAdInit()
+
+ expect(mockPubads.refresh).toHaveBeenCalled()
})
- it('fires burl via sendBeacon on slotRenderEnded when our bid won', () => {
+ it('fires burl via sendBeacon on slotRenderEnded when our bid won', async () => {
const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true)
- // ... setup and trigger slotRenderEnded event
- // Verify: navigator.sendBeacon called with burl
+ let capturedListener: ((e: any) => void) | undefined
+
+ const mockSlot = {
+ addService: vi.fn().mockReturnThis(),
+ setTargeting: vi.fn().mockReturnThis(),
+ getSlotElementId: vi.fn().mockReturnValue('atf'),
+ getTargeting: vi.fn().mockReturnValue(['abc']),
+ }
+ const mockPubads = {
+ enableSingleRequest: vi.fn(),
+ refresh: vi.fn(),
+ addEventListener: vi.fn((event: string, fn: (e: any) => void) => {
+ if (event === 'slotRenderEnded') capturedListener = fn
+ }),
+ }
+ ;(window as any).googletag = {
+ cmd: { push: vi.fn((fn: () => void) => fn()) },
+ defineSlot: vi.fn().mockReturnValue(mockSlot),
+ pubads: vi.fn().mockReturnValue(mockPubads),
+ enableServices: vi.fn(),
+ }
+ ;(window as any).__ts_ad_slots = [
+ {
+ id: 'atf',
+ gam_unit_path: '/123/atf',
+ div_id: 'atf',
+ formats: [[300, 250]],
+ targeting: {},
+ },
+ ]
+ ;(window as any).__ts_request_id = 'rid-burl-test'
+
+ vi.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ atf: {
+ hb_pb: '1.00',
+ hb_bidder: 'kargo',
+ hb_adid: 'abc',
+ burl: 'https://ssp/bill',
+ },
+ }),
+ } as Response)
+
+ const { installTsAdInit } = await import('./index')
+ installTsAdInit()
+ await (window as any).__tsAdInit()
+
+ // Trigger slotRenderEnded — slot has our winning hb_adid
+ expect(capturedListener).toBeDefined()
+ capturedListener!({
+ isEmpty: false,
+ slot: mockSlot,
+ })
+
+ expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill')
beaconSpy.mockRestore()
})
})
```
Run: `cd crates/js/lib && npx vitest run`
- Expected: FAIL — `installTsAdInit` not exported
+ Expected: FAIL — `installTsAdInit` not exported or fetches wrong endpoint
- [ ] **Step 2: Add `installTsAdInit` to `index.ts`**
- Add to `crates/js/lib/src/integrations/gpt/index.ts` (bottom of file):
+ Add to `crates/js/lib/src/integrations/gpt/index.ts`:
```typescript
interface TsAdSlot {
@@ -1505,60 +2023,87 @@ The TypeScript version is the authoritative implementation; it must mirror the R
type TsWindow = Window & {
__ts_ad_slots?: TsAdSlot[]
- __ts_bids?: Record
+ __ts_request_id?: string
__tsAdInit?: () => void
}
/**
- * Install `window.__tsAdInit` — reads `window.__ts_ad_slots` and `window.__ts_bids`
- * (injected by the edge into ), defines GPT slots, applies pre-won bid targeting,
- * registers a `slotRenderEnded` listener to fire `burl` via `sendBeacon`, then calls
- * `refresh()`.
+ * Install `window.__tsAdInit`.
+ *
+ * Reads `window.__ts_ad_slots` and `window.__ts_request_id` (both injected by
+ * the edge at `` open). Fetches bid results from `/ts-bids?rid=`
+ * concurrently with GPT slot definition. Applies targeting and calls `refresh()`
+ * after the fetch resolves. Registers `slotRenderEnded` to fire `burl` via
+ * `sendBeacon` when our specific Prebid bid wins the GAM line item match.
*/
export function installTsAdInit(): void {
const w = window as TsWindow
w.__tsAdInit = function () {
const slots = w.__ts_ad_slots ?? []
- const bids = w.__ts_bids ?? {}
+ const rid = w.__ts_request_id
+
+ const bidsPromise: Promise> = rid
+ ? fetch(`/ts-bids?rid=${encodeURIComponent(rid)}`, {
+ credentials: 'omit',
+ })
+ .then((r) => (r.ok ? r.json() : {}))
+ .catch(() => ({}))
+ : Promise.resolve({})
+
const g = (window as GptWindow).googletag
if (!g) return
+
g.cmd.push(() => {
- slots.forEach((slot) => {
- const gptSlot = g.defineSlot?.(
- slot.gam_unit_path,
- slot.formats,
- slot.div_id
- )
- if (!gptSlot) return
- gptSlot.addService(g.pubads())
- Object.entries(slot.targeting ?? {}).forEach(([k, v]) =>
- gptSlot.setTargeting(k, v)
- )
- const bid = bids[slot.id] ?? {}
- ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => {
- if (bid[key]) gptSlot.setTargeting(key, bid[key]!)
+ const gptSlots = slots
+ .map((slot) => {
+ const gptSlot = g.defineSlot?.(
+ slot.gam_unit_path,
+ slot.formats,
+ slot.div_id
+ )
+ if (!gptSlot) return null
+ gptSlot.addService(g.pubads())
+ Object.entries(slot.targeting ?? {}).forEach(([k, v]) =>
+ gptSlot.setTargeting(k, v)
+ )
+ return { id: slot.id, gptSlot }
})
- })
+ .filter(Boolean) as Array<{
+ id: string
+ gptSlot: NonNullable>
+ }>
+
g.pubads().enableSingleRequest()
g.enableServices()
- g.pubads().addEventListener?.('slotRenderEnded', (event: any) => {
- const slotId: string = event.slot?.getSlotElementId?.() ?? ''
- const bid = bids[slotId] ?? {}
- if (
- !event.isEmpty &&
- bid.burl &&
- event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid
- ) {
- navigator.sendBeacon(bid.burl)
- }
+
+ bidsPromise.then((bids) => {
+ gptSlots.forEach(({ id, gptSlot }) => {
+ const bid = bids[id] ?? {}
+ ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => {
+ if (bid[key]) gptSlot.setTargeting(key, bid[key]!)
+ })
+ })
+
+ g.pubads().addEventListener?.('slotRenderEnded', (event: any) => {
+ const slotId: string = event.slot?.getSlotElementId?.() ?? ''
+ const bid = bids[slotId] ?? {}
+ if (
+ !event.isEmpty &&
+ bid.burl &&
+ event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid
+ ) {
+ navigator.sendBeacon(bid.burl)
+ }
+ })
+
+ g.pubads().refresh()
})
- g.pubads().refresh()
})
}
}
```
- Call `installTsAdInit()` from the integration's initialization path so it's set up when the bundle loads.
+ Call `installTsAdInit()` from the integration's initialization path.
- [ ] **Step 3: Run JS tests**
@@ -1574,12 +2119,12 @@ The TypeScript version is the authoritative implementation; it must mirror the R
```bash
git add crates/js/lib/src/integrations/gpt/
- git commit -m "Add __tsAdInit and slotRenderEnded burl firing to GPT integration"
+ git commit -m "Add installTsAdInit with /ts-bids fetch pattern and slotRenderEnded burl firing"
```
---
-## Task 11: `nurl` fire-and-forget
+## Task 13: `nurl` fire-and-forget
**Files:**
@@ -1610,9 +2155,9 @@ The TypeScript version is the authoritative implementation; it must mirror the R
fn default_fire_nurl_at_edge() -> bool { true }
```
-- [ ] **Step 3: Fire nurls in publisher.rs after auction**
+- [ ] **Step 3: Fire nurls in publisher.rs after bid_cache.put()**
- After `auction_result` is obtained, add:
+ After the `bid_cache.put(...)` call (Task 9 Step 3), add:
```rust
if let Some(ref result) = auction_result {
@@ -1620,7 +2165,7 @@ The TypeScript version is the authoritative implementation; it must mirror the R
}
```
- Add helper (no `.await` — fire-and-forget):
+ Add helper:
```rust
fn fire_winning_nurls(
@@ -1671,13 +2216,13 @@ The TypeScript version is the authoritative implementation; it must mirror the R
---
-## Task 12: End-to-end integration tests
+## Task 14: End-to-end integration tests
**Files:**
- Modify: `crates/trusted-server-core/src/publisher.rs` (test module)
-Tests use `pub(crate)` helpers from Task 8 directly.
+Tests use `pub(crate)` helpers from Task 9 directly.
- [ ] **Step 1: Write tests**
@@ -1686,7 +2231,7 @@ Tests use `pub(crate)` helpers from Task 8 directly.
```rust
#[cfg(test)]
mod creative_opportunities_tests {
- use super::{build_ad_slots_script, build_ad_bids_script, html_escape_for_script};
+ use super::{build_head_globals_script, build_bid_map, html_escape_for_script};
use crate::creative_opportunities::{
CreativeOpportunitiesConfig, CreativeOpportunitySlot, CreativeOpportunityFormat,
CreativeOpportunitiesFile, match_slots,
@@ -1719,20 +2264,32 @@ Tests use `pub(crate)` helpers from Task 8 directly.
}
#[test]
- fn ad_slots_script_is_safe_and_parseable() {
+ fn head_globals_script_contains_ad_slots_and_request_id() {
let slots = vec![make_slot()];
let config = make_config();
- let script = build_ad_slots_script(&slots, &config);
- assert!(script.contains("window.__ts_ad_slots=JSON.parse"), "should use JSON.parse");
+ let rid = "550e8400-e29b-41d4-a716-446655440000";
+ let script = build_head_globals_script(&slots, rid, &config);
+ assert!(script.contains("window.__ts_ad_slots=JSON.parse"), "should use JSON.parse for slots");
assert!(script.contains("atf_sidebar_ad"), "should include slot id");
- // Verify no raw < or > that could break HTML parser
- let inner = script.trim_start_matches("");
+ assert!(script.contains(&format!("window.__ts_request_id=\"{rid}\"")), "should include request_id");
+ assert!(!script.contains("__ts_bids"), "must NOT contain bids — bids come from /ts-bids");
+ }
+
+ #[test]
+ fn head_globals_script_is_xss_safe() {
+ let slots = vec![make_slot()];
+ let config = make_config();
+ let script = build_head_globals_script(&slots, "safe-rid", &config);
+ // Strip outer ");
assert!(!inner.contains('<'), "no unescaped < in script content");
assert!(!inner.contains('>'), "no unescaped > in script content");
}
#[test]
- fn ad_bids_script_uses_price_bucket_and_ad_id() {
+ fn bid_map_uses_price_bucket_and_ad_id() {
let mut winning_bids = HashMap::new();
winning_bids.insert("atf_sidebar_ad".to_string(), Bid {
slot_id: "atf_sidebar_ad".to_string(),
@@ -1747,11 +2304,23 @@ Tests use `pub(crate)` helpers from Task 8 directly.
ad_id: Some("prebid-uuid-abc123".to_string()),
metadata: HashMap::new(),
});
- let script = build_ad_bids_script(&winning_bids, PriceGranularity::Dense);
- assert!(script.contains("\"hb_pb\":\"2.53\""), "should bucket 2.53 as 2.53 (dense)");
- assert!(script.contains("\"hb_bidder\":\"kargo\""), "should include bidder");
- assert!(script.contains("\"hb_adid\":\"prebid-uuid-abc123\""), "should use ad_id not creative markup");
- assert!(script.contains("burl"), "should include burl for billing");
+ let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense);
+ let slot_bids = bid_map.get("atf_sidebar_ad").expect("should have slot bids");
+ assert_eq!(
+ slot_bids.get("hb_pb").and_then(|v| v.as_str()),
+ Some("2.53"),
+ "should bucket 2.53 as 2.53 (dense)"
+ );
+ assert_eq!(
+ slot_bids.get("hb_bidder").and_then(|v| v.as_str()),
+ Some("kargo"),
+ "should include bidder"
+ );
+ assert_eq!(
+ slot_bids.get("hb_adid").and_then(|v| v.as_str()),
+ Some("prebid-uuid-abc123"),
+ "should use ad_id not creative markup"
+ );
}
#[test]
@@ -1795,7 +2364,7 @@ Tests use `pub(crate)` helpers from Task 8 directly.
```bash
git add crates/trusted-server-core/src/publisher.rs
- git commit -m "Add integration tests for creative opportunities pipeline (slots, bids, XSS)"
+ git commit -m "Add integration tests for creative opportunities pipeline (head globals, bid map, XSS)"
```
---
@@ -1804,19 +2373,27 @@ Tests use `pub(crate)` helpers from Task 8 directly.
Run `fastly compute serve` and verify:
-- [ ] **No match:** Request `/about` — no `__ts_ad_slots` or `__ts_bids` in response HTML, no `Cache-Control: private, no-store`
-- [ ] **Match:** Request `/2024/01/article` — both globals present in ``, `Cache-Control: private, no-store` set
-- [ ] **Empty file kill-switch:** Empty `creative-opportunities.toml` → no globals injected on any URL
-- [ ] **Auction timeout:** Set `auction_timeout_ms = 1` → `__ts_bids` injects as `{}`, no slot entries
-- [ ] **XSS check:** Add `targeting = { zone = "
-```
-
-> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped
-> before insertion into the `, ContentType::Html)`.
+
+> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped
+> before insertion into the `"#
- .to_string()
+ r#""#.to_string()
),
+ ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)),
};
let mut processor = create_html_processor(config);
let output = processor
- .process_chunk(b"T", true)
+ .process_chunk(b"Tcontent", true)
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
assert!(html.contains("window.__ts_ad_slots"), "should inject ad slots at head-open");
- assert!(html.contains("window.__ts_request_id"), "should inject request_id at head-open");
+ assert!(!html.contains("__ts_request_id"), "must NOT inject request_id — body-injection arch has no request_id");
}
#[test]
- fn does_not_hold_end_of_head() {
- // Verify: no bid data appears before — that hold was rejected by spec §4.3
+ fn injects_ts_bids_before_body_close() {
+ let bids_script = r#""#;
+ let state = std::sync::Arc::new(std::sync::RwLock::new(
+ Some(bids_script.to_string())
+ ));
let config = HtmlProcessorConfig {
origin_host: "origin.example.com".to_string(),
request_host: "example.com".to_string(),
request_scheme: "https".to_string(),
integrations: IntegrationRegistry::empty_for_tests(),
ad_slots_script: None,
+ ad_bids_state: state,
};
let mut processor = create_html_processor(config);
let output = processor
- .process_chunk(b"T", true)
+ .process_chunk(b"content", true)
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
- assert!(!html.contains("__ts_bids"), "must not inject bids into head");
+ assert!(html.contains("window.__ts_bids"), "should inject bids before ");
+ let bids_pos = html.find("window.__ts_bids").expect("bids should be in output");
+ let body_close_pos = html.find("").expect("
content should be in output");
+ assert!(bids_pos < body_close_pos, "bids must appear before ");
+ }
+
+ #[test]
+ fn injects_empty_ts_bids_when_state_is_none() {
+ let state = std::sync::Arc::new(std::sync::RwLock::new(None));
+ let config = HtmlProcessorConfig {
+ origin_host: "origin.example.com".to_string(),
+ request_host: "example.com".to_string(),
+ request_scheme: "https".to_string(),
+ integrations: IntegrationRegistry::empty_for_tests(),
+ ad_slots_script: None,
+ ad_bids_state: state,
+ };
+ let mut processor = create_html_processor(config);
+ let output = processor
+ .process_chunk(b"