Skip to content

Implement server-side ad slot templates with PBS and APS auction#680

Merged
aram356 merged 153 commits into
mainfrom
server-side-ad-templates-impl
Jul 7, 2026
Merged

Implement server-side ad slot templates with PBS and APS auction#680
aram356 merged 153 commits into
mainfrom
server-side-ad-templates-impl

Conversation

@prk-Jr

@prk-Jr prk-Jr commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds server-side ad slot templates under [creative_opportunities] in trusted-server.toml. Matching slots are selected from the incoming document URL at the edge, and window.tsjs.adSlots is injected at <head> open so initial GPT setup does not need a separate slot-discovery request.
  • Runs the server-side auction in parallel with the origin fetch for eligible document navigations. Configured providers such as Prebid Server and APS race under the creative-opportunity auction deadline; winning bids are price-bucketed and injected as window.tsjs.bids before </body>.
  • Adds the window.tsjs.adInit GPT runtime path. It reads window.tsjs.adSlots and window.tsjs.bids synchronously, defines or reuses GPT slots, applies slot-level and hb_* targeting, sets ts_initial=1, refreshes the initial slots, and fires nurl/burl only after slotRenderEnded confirms the TS bid won via hb_adid.
  • Adds PBS stored-request fallback keyed by slot ID when no inline PBS bidder params are present, filters non-PBS provider keys from PBS requests, and keeps bidder-param override rules for per-bidder/zone params.
  • Adds per-bidder PBS win/billing suppression with [integrations.prebid].suppress_nurl_bidders, while retaining the deployment-wide [integrations.prebid].suppress_nurl compatibility switch.
  • Improves the deferred slim-Prebid refresh path so GPT refresh auctions derive ad unit sizes and zone from injected window.tsjs.adSlots / GPT metadata instead of placeholder refresh sizes.
  • Implements EID forwarding for the server-side auction path: reads the ts-eids cookie written by TSJS, gates EIDs by consent, and forwards them as OpenRTB user.ext.eids to PBS.
  • Forwards real client IP and geo from Fastly ClientInfo into the server-side PBS request so bidders see the browser client context rather than the Fastly edge context.
  • Keeps SPA/CSR route changes covered by the existing GET /__ts/page-bids hook, which updates window.tsjs.adSlots / window.tsjs.bids and re-runs window.tsjs.adInit() after pushState, replaceState, or popstate navigations.

What the server-side auction sends to PBS

Field Value Source
user.id EC ID ts-ec cookie or generated EC identity
user.consent / user.ext.consent TCF v2 string when available consent cookies / request consent extraction
user.ext.eids EID array, consent-gated ts-eids cookie plus EC identity resolution
user.ext.ec_fresh fresh EC flag EC context
device.ua user agent User-Agent header
device.ip real client IP Fastly ClientInfo.client_ip
device.geo city/country/region/lat/lon/metro/asn Fastly geo lookup
device.dnt true if set DNT: 1 header
device.language primary language tag Accept-Language header
site.domain / site.page publisher domain + full URL request host/path/scheme
site.ref referrer Referer header
regs.gdpr / regs.us_privacy / regs.gpp privacy signals consent extraction
imp.* slots, formats, bidder params, floors [creative_opportunities] slot templates and Prebid config
tmax auction timeout ms [creative_opportunities].auction_timeout_ms, falling back to [auction].timeout_ms

Changes

File / area Change
trusted-server.toml Adds [creative_opportunities] slot templates and documents Prebid nurl suppression knobs.
.env.example / docs Documents Prebid bidder override env vars and SUPPRESS_NURL_BIDDERS.
crates/trusted-server-core/src/creative_opportunities.rs Defines slot-template config, URL glob matching, slot-to-auction conversion, provider params, and slot ID validation helpers.
crates/trusted-server-core/src/settings.rs / build.rs Wires creative opportunities into settings and build-time slot ID validation from trusted-server.toml.
crates/trusted-server-core/src/publisher.rs Matches slots, dispatches server-side auctions, injects window.tsjs.adSlots and window.tsjs.bids, applies cache-control safeguards, handles page-bids responses, and forwards client context.
crates/trusted-server-core/src/html_processor.rs Adds head-open and bounded body-close injection points with a guard against duplicate tsjs.bids injection.
crates/trusted-server-core/src/auction/* Extends auction types, request parsing, provider orchestration, floor filtering, diagnostics, and provider response handling.
crates/trusted-server-core/src/integrations/prebid.rs Adds OpenRTB enrichment, stored-request fallback, EID forwarding, bidder override rules, PBS cache fields, nurl/burl propagation, and per-bidder suppression.
crates/trusted-server-core/src/integrations/aps.rs Adds APS/TAM provider support.
crates/trusted-server-core/src/integrations/adserver_mock.rs Adds mock mediation support with decoded provider prices.
crates/trusted-server-core/src/integrations/gpt.rs / gpt_bootstrap.js Adds the head-injected window.tsjs.adInit bootstrap path.
crates/js/lib/src/integrations/gpt/index.ts Implements the browser GPT path for window.tsjs.adSlots, window.tsjs.bids, render-confirmed beacon firing, SPA updates, slim-Prebid loading, and Prebid creative rendering bridge.
crates/js/lib/src/integrations/prebid/index.ts Adds deferred Prebid integration, user ID module/EID cookie sync, client-side bidder support, and metadata-derived refresh auctions.
crates/trusted-server-adapter-fastly/src/main.rs Wires auction/page-bids routes and publisher handler inputs into the Fastly adapter.

Closes

Closes #677
Closes #697
Closes #698
Closes #699
Closes #700
Closes #702

Test plan

Automated

  • cargo test --workspace
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • git diff --check
  • JS build: cd crates/js/lib && node build-all.mjs
  • JS lint: cd crates/js/lib && npm run lint
  • JS format: cd crates/js/lib && npm run format
  • Docs format: cd docs && npm run format
  • GitHub Vitest check passes on the current PR head
  • Local cd crates/js/lib && npx vitest run currently fails before test discovery in this workspace with ERR_REQUIRE_ESM from html-encoding-sniffer requiring @exodus/bytes/encoding-lite.js; no local JS test assertions execute under that failure mode.

Manual end-to-end (browser DevTools console)

The steps below build on each other. Use a URL whose path matches one of the configured [[creative_opportunities.slot]] page_patterns.

Step 1 - Verify slot config is injected at <head> open

window.tsjs?.adSlots

Expected: an array of slot objects. Each entry has id, gam_unit_path, div_id, formats, and targeting. Note the div_id value from one matching slot for step 3.

Step 2 - Verify server-side auction results are injected before </body>

window.tsjs?.bids

Expected: an object keyed by slot ID. Winning slots include hb_bidder, hb_pb, and, for Prebid cache-backed bids, hb_adid / cache fields.

Step 3 - Verify window.tsjs.adInit wired GPT targeting

Replace SLOT_DIV_ID with the div_id from step 1.

googletag
  .pubads()
  .getSlots()
  .filter((slot) => slot.getSlotElementId() === 'SLOT_DIV_ID')
  .map((slot) => ({
    path: slot.getAdUnitPath(),
    targeting: slot.getTargetingMap(),
  }))

Expected: the slot has the configured GAM unit path and targeting includes hb_pb, hb_bidder, any slot-level keys such as pos / zone, and ts_initial: ["1"].

Step 4 - Verify slot matching is page-pattern-aware

Navigate to a different configured path, for example / when homepage slots are configured, and repeat step 2.

Expected: window.tsjs.bids is keyed by the slots matching that page, not by slots from the previous page type.

Step 5 - Confirm no duplicate bids injection

View page source and search for .bids=JSON.parse.

Expected: exactly one window.tsjs.bids assignment before </body> on pages where an auction ran.

Pending (GAM line items required)

Creative delivery requires standard GAM line items targeting hb_pb, hb_bidder, and related Prebid keys. That setup is outside this PR.

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code
  • Uses log macros instead of println!
  • New code paths have Rust and/or TS test coverage
  • No secrets or credentials intentionally committed

jevansnyc and others added 26 commits April 15, 2026 20:47
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Incorporate all review feedback (aram356 + jevansnyc): cache contract,
  consent/GDPR gating, async restructuring detail, CreativeOpportunityFormat
  schema, glob pattern fix, XSS escaping, win notifications, APS params,
  timeout config key, defineSlot fix, gpt.rs ownership, KV migration path,
  Phase 2 sketch
- Fix Prettier formatting (format-docs CI)
- Add implementation plan (12 tasks, TDD, ordered by dependency)
- Incorporate all review feedback (aram356 + jevansnyc): cache contract,
  consent/GDPR gating, async restructuring detail, CreativeOpportunityFormat
  schema, glob pattern fix, XSS escaping, win notifications, APS params,
  timeout config key, defineSlot fix, gpt.rs ownership, KV migration path,
  Phase 2 sketch
- Fix Prettier formatting (format-docs CI)
- Add implementation plan (12 tasks, TDD, ordered by dependency)
Replace the head-injected __ts_bids design with a server-cached bid
delivery model fetched by the client via a new /ts-bids endpoint. The
auction never blocks page rendering — </head> flushes immediately, body
parses without waiting for bids, and the client fetches bids in parallel
with content paint.

Key changes:
- §2 Goal: bid delivery decoupled from page rendering; FCP unchanged from
  no-TS baseline
- §4.3 Auction Trigger: drop buffered/streaming dichotomy; single mode
  forces chunked encoding on all origins (WordPress, NextJS, etc.)
- §4.4 Head Injection: only __ts_ad_slots and __ts_request_id injected at
  <head> open; bid results moved to /ts-bids endpoint
- §4.6 Client Residual: __tsAdInit defines slots immediately, fetches
  bids via /ts-bids, applies targeting and fires refresh() after resolve
- §4.7 (new) Caching Behavior: explicit cacheability table for HTML, JS,
  CSS, tsjs bundle, bid results; Fastly edge HTTP cache leveraged for
  origin HTML
- §5 Request-Time Sequence: full mermaid diagram covering content +
  creative + burl flow with cache-hit and cache-miss branches; separate
  text sequences for cache-hit (~80ms FCP, ~900ms ad-visible) and
  cache-miss (~250ms FCP, ~1,050ms ad-visible)
- §6 Performance Summary: cache-hit and cache-miss columns; FCP added
  as a tracked metric
- §7 Implementation Scope: add bid_cache.rs, /ts-bids endpoint, force
  chunked encoding step
- §8 Edge Cases: origin-agnostic entries; new entries for /ts-bids 404
  and client-never-fetches-/ts-bids

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pivot from the /ts-bids fetch endpoint + in-process bid_cache design to
inline __ts_bids injection before </body>. The earlier design relied on
shared state that doesn't reliably survive Fastly Compute's per-request Wasm
isolate model — body injection achieves the same FCP property in a single
response with no shared-state requirement.

Key changes:

- §4.3: replace /ts-bids long-poll with bounded </body> hold tied to
  A_deadline. Body content above </body> paints first; close-tag held
  until auction completes or A_deadline fires (graceful __ts_bids = {}
  fallback).
- §4.3: add auction-eligibility gating (consent, bot UA, prefetch hints,
  HEAD method, slot match) so auctions fire on real first-page-load
  impressions only.
- §4.4: replace __ts_request_id + /ts-bids machinery with two inline
  <script> blocks — __ts_ad_slots at <head> open, __ts_bids before
  </body> via lol_html el.on_end_tag().
- §4.5: move both nurl and burl to client-side firing from
  slotRenderEnded after hb_adid match. Server-side firing rejected to
  avoid billing inflation on bids that never render.
- §4.6: replace fetch+Promise pattern with synchronous __ts_bids read.
  Add lazy slim-Prebid loader (post-window.load) for scroll/refresh
  auctions and Phase B identity warm-up. Add ts_initial=1 slot-ownership
  sentinel.
- §4.7: switch Cache-Control from private, no-store to private,
  max-age=0 to preserve browser BFCache eligibility while still
  preventing intermediate-cache leaks.
- §4.8 (new): document the EC/KV identity model as load-bearing auction
  input — Phase A retrieval at request time, Phase B post-render
  enrichment via slim-Prebid userID modules. Add bare-EC first-impression
  caveat and auction_eid_count metric. Note federated-consortium
  passphrase property and clickstream-compounding speed win.
- §5: update mermaid + cache-hit/miss timelines for bounded body hold;
  ad-visible converges to ~870ms (hit) / ~1,020ms (miss).
- §6: drop /ts-bids RTT row; add DCL row; add clickstream-compounding,
  TS-overhead, identity-coverage, and confidence-interval framing.
- §7: drop bid_cache.rs and /ts-bids endpoint from scope; add
  auction-eligibility gating and slim-Prebid bundle build target. Add
  explicit "Deleted" subsection.
- §8: drop /ts-bids edge cases; add SPA/pushState, bare-EC, bot/prefetch,
  HEAD, BFCache restoration cases.
- §9.6: server-side GAM downgraded from "Phase 2 commitment" to
  aspirational and contingent on Google agreement. §9.8 (slim-Prebid
  bundle composition), §9.9 (Privacy Sandbox), §9.10 (per-bidder consent)
  added as follow-ups.

Implementation plan at docs/superpowers/plans/2026-04-30-server-side-ad-templates.md
is now stale relative to this spec; needs regenerating before code lands.
…ities.toml

Adds the creative_opportunities field to Settings struct to deserialize
configuration for the server-side ad auction feature. Includes build.rs
stubs for types required during build-time configuration validation.

Creates creative-opportunities.toml with example slot configuration and
updates trusted-server.toml with the [creative_opportunities] section
defining GAM network ID, auction timeout, and price granularity settings.

Tests pass with proper TOML parsing of the creative_opportunities section.
…ared auction state

- Add `ad_slots_script: Option<String>` and `ad_bids_state: Arc<RwLock<Option<String>>>` fields to `HtmlProcessorConfig`
- Update `from_settings` to initialize both new fields with safe defaults
- Prepend `ad_slots_script` inside the existing `<head>` handler before integration inserts
- Add `element!("body", ...)` handler that uses `end_tag_handlers()` to inject `__ts_bids` before `</body>`; falls back to empty `{}` when auction state is `None`
- Add `IntegrationRegistry::empty_for_tests()` test helper
- Add three new tests covering all injection paths
…gibility gates; max-age=0

- Make handle_publisher_request async; add orchestrator and slots_file params
- Dispatch origin request with send_async before running auction in parallel
- Gate auction on GET, no prefetch, no bot, matched slots, TCF purpose-1 consent
- Run server-side auction and write bucketed bids to ad_bids_state Arc<RwLock>
- Compute ad_slots_script after response headers; set Cache-Control: private, max-age=0
- Fix Stream arm to thread actual ad_slots_script and ad_bids_state through
- Add build_auction_request, build_bid_map, build_bids_script, build_ad_slots_script helpers
- Update route_tests.rs to pass empty slots_file to route_request
- build_bid_map now returns serde_json::Map with full bid objects (hb_pb,
  hb_bidder, hb_adid, nurl, burl) instead of a plain CPM string map
- build_bids_script / build_ad_slots_script now emit full <script> tags
  using JSON.parse("…") for safe inline embedding; add html_escape_for_script helper
- build_ad_slots_script uses correct property names (gam_unit_path, div_id,
  formats, targeting) matching the client-side TSJS bundle expectations
- Replace map_or(false, …) with is_some_and(…) on lines 546, 549, 567
- Add # Panics doc sections to handle_publisher_request and create_html_processor
… from slotRenderEnded; slim-Prebid lazy loader
- Enable APS and adserver_mock in auction config; set providers and mediator
- Increase auction_timeout_ms from 500ms to 3000ms — 500ms was too tight
  for HTTPS round-trips to mocktioneer, leaving the mediator zero budget
- Fix mediation request: send numeric price instead of opaque encoded_price;
  mocktioneer requires a decoded price field and does not support encoded_price
- Expand creative-opportunities slot page_patterns to include /news/**
@prk-Jr prk-Jr self-assigned this May 6, 2026
Define SlotRenderEndedEvent, SlotRenderEvent, and TestWindow types to
eliminate all @typescript-eslint/no-explicit-any violations in
gpt/index.ts and gpt/index.test.ts. Extend GptWindow with
__tsjs_slim_prebid_url so installSlimPrebidLoader avoids the any cast.
@prk-Jr prk-Jr changed the title Implement server-side ad slot templates with APS auction Implement server-side ad slot templates with PBS and APS auction May 6, 2026
Set gam_network_id to 88059007 (autoblog production network). Update
atf_sidebar_ad slot to /88059007/autoblog/news with div_id
ad-atf_sidebar-0-_r_2_ (desktop ATF sidebar, 300x250); restrict
page_patterns to article paths only (/20**, /news/**) since that div
does not exist on the homepage. Add homepage_header_ad slot targeting
/88059007/autoblog/homepage with ad-header-0-_R_jpalubtak5lb_ for
970x90/728x90/970x250 leaderboard formats. Reduce auction_timeout_ms
from 3000 to 500 to cap TTFB at the spec-recommended ceiling.
prk-Jr added 3 commits June 29, 2026 21:53
…tes-impl

Reconcile the server-side ad-template/auction work with main's EdgeZero
canary rollout, the Axum/Cloudflare/Spin adapters, and the edgezero
into_bytes() repin.

- main(): route to EdgeZero only when the canary rollout selects it and
  the settings carry no creative_opportunity slots (combine both gates).
- Thread the new handle_publisher_request(kv, ec_context, auction)
  parameters through the Axum, Cloudflare, and Spin adapters with an
  empty AuctionDispatch — server-side auction stays deferred there, as on
  the Fastly EdgeZero path.
- Keep HEAD's apply_floor_prices None-price drop and the corrected
  publisher OwnedProcessResponseParams / single stream_publisher_body;
  union the diverged publisher, html_processor, and orchestrator tests.
- Drop the stale into_bytes().unwrap_or_default() test calls and add the
  new HtmlProcessorConfig / PlatformBackendSpec fields and the
  route_request slots argument to the bench and adapter tests.
Blocking:
- Move tokio to [dev-dependencies] in trusted-server-core; it was only used
  by #[tokio::test] and was linking the runtime into the wasm prod build.
  Confirmed the release wasm adapter build no longer pulls tokio.
- Roll back the SPA currentPath on a failed /__ts/page-bids fetch so a
  transient error no longer permanently strands that route (gpt/index.ts).

Build/runtime parity and diagnostics:
- Bound creative-opportunity format width/height to u32 range at build time
  so values the runtime u32 cannot hold are rejected early.
- Add #[serde(deny_unknown_fields)] to the build.rs config stub to match the
  runtime type and reject mistyped table keys at build time.
- Warn when the <body> end-tag handler is absent so a silently non-rendering
  server-side ad feature is diagnosable.
- Log dropped slot bidders that are neither configured nor the aps provider.
- Log build_bid_index collisions (multiple bids per seat/imp).

JS correctness:
- Narrow uid.atype to a number before the range check in sanitizeAuctionUid.
- Resolve findInjectedSlotForRefresh by exact/container match before the
  prefix fallback, with a regression test for prefix-overlapping div_ids.
- Guard the gpt_bootstrap prefix scan against an empty div_id.
- Route injectAdmIntoSlot through findSlotElementByDivId for consistency.

Cleanup and docs:
- Remove the dead has_post_processors routing dependency from
  classify_response_route and (now unused) handle_publisher_request.
- Extract the duplicated EID resolution/consent-gating/device tail shared by
  the initial-page and page-bids dispatch paths into one helper.
- Anchor the surrogate cache-header list in a shared const so the legacy and
  EdgeZero Set-Cookie privacy paths stay aligned.
- Refresh stale docs (PublisherResponse::Stream, the publisher module
  platform-coupling note, and UserInfo.eids consent-gate location).
Moving tokio to trusted-server-core dev-dependencies removed it from the
crate's normal dependency list, so the integration-tests lockfile (which
resolves core's non-dev deps) no longer pins tokio under core. Keeps
`cargo --locked` green for the integration job.

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review: I reviewed PR #680 at ae9d50a against main, including the current diff, CI, and existing review threads. CI is green and I did not find a P0/blocking security or data-loss issue, but I found one high-severity compatibility/correctness issue for the non-Fastly adapters plus a few medium-risk edge cases in the new streaming and browser paths.

Review Summary

This is a very large server-side ad-template change spanning Rust adapters/core and browser GPT/Prebid code. The current Fastly legacy path appears to have the expected route wiring, consent gates, cache protection, and auction lifecycle; the highest remaining risk is adapter parity: Axum/Cloudflare/Spin accept the new config and inject the browser hook, but do not actually wire the server-side slot templates or /__ts/page-bids endpoint.

Findings

P0 / Blockers

None found.

P1 / High

  1. Non-Fastly adapters silently disable configured slot templates and lack the SPA page-bids endpointcrates/trusted-server-adapter-axum/src/app.rs:174
    • Issue: Axum, Cloudflare, and Spin all build an AuctionDispatch with slots: &[] (cloudflare/src/app.rs:303, spin/src/app.rs:605 have the same pattern), and none of those adapters registers GET/OPTIONS /__ts/page-bids. At the same time the shared GPT integration can still install installSpaAuctionHook(), which fetches /__ts/page-bids on SPA navigation. Operators can configure [[creative_opportunities.slot]] successfully, but these adapters ignore it and route the SPA fetch to the publisher origin/fallback instead of the core handler.
    • Why it matters: This is a cross-adapter compatibility trap. A config that works on Fastly legacy silently loses initial server-side slots/auctions on Axum/Cloudflare/Spin, and SPA navigations can make internal endpoint requests to the origin, typically producing non-JSON failures and no refreshed server-side bids.
    • Suggested fix: Either wire these adapters to pass state.settings.creative_opportunity_slots() and register the same /__ts/page-bids GET plus fail-closed OPTIONS behavior, or fail startup / suppress the GPT SPA hook when configured slots are present on adapters where server-side templates are intentionally unsupported.

P2 / Medium

  1. Slim-loaded Prebid misses user-ID module initialization after window.loadcrates/trusted-server-js/lib/src/integrations/prebid/index.ts:928

    • Issue: The GPT slim loader appends the Prebid script from a window.load handler. When that deferred script executes, window.load has already fired, but Prebid self-init only schedules installUserIdModules() via another window.addEventListener('load', ...).
    • Why it matters: In the slim-loaded path, the user-sync/EID warm-up setup can be skipped, so later server-side auctions lose the identity enrichment this PR is trying to preserve.
    • Suggested fix: During Prebid self-init, call installUserIdModules() immediately when document.readyState === 'complete'; otherwise register the load listener with { once: true }.
  2. Raw </body byte scanning can hold the stream on non-tag textcrates/trusted-server-core/src/publisher.rs:900

    • Issue: BodyCloseHoldBuffer starts the auction hold at the first case-insensitive byte sequence </body, regardless of HTML parser state. That also matches strings/comments/raw-text content such as an inline script containing "</body>" before the real closing body tag.
    • Why it matters: A false positive near the top of the page would hold the rest of the document behind auction collection, defeating the intended “only delay the body tail / DCL” behavior and making performance dependent on page text.
    • Suggested fix: Detect the actual body end through the HTML processor/parser state, or make the scanner HTML-aware enough to ignore comments, quoted attributes, and raw-text elements. Add a regression test with </body> inside a script before visible body content.
  3. Prefix-based slot lookup can misattribute overlapping dynamic div IDscrates/trusted-server-js/lib/src/integrations/gpt/index.ts:48

    • Issue: After exact lookup fails, findSlotElementByDivId() returns the first DOM id that starts with the configured prefix. With overlapping configured prefixes such as ad- and ad-header-, DOM order can let the shorter prefix claim the longer slot’s element/iframe.
    • Why it matters: The render bridge then resolves the message source to the wrong slot, rejects the real bid/slot match, and can skip creative rendering or win/billing beacons for valid server-side bids.
    • Suggested fix: Prefer exact/container matches across all configured slots first, then resolve prefix matches by longest prefix (or by a precomputed divToSlotId map). Add an overlapping-prefix regression test.

P3 / Low

None.

CI / Existing Reviews

gh pr checks 680 reports all current checks passing, including CodeQL, cargo fmt/tests, adapter checks, browser integration tests, vitest, and docs/TS formatting. I reviewed existing review comments and avoided re-reporting items that were already raised and fixed in prior passes.

Comment thread crates/trusted-server-adapter-axum/src/app.rs Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/prebid/index.ts Outdated
Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts
prk-Jr and others added 6 commits June 30, 2026 10:07
These EdgeZero-style adapters finalize buffered, and the sync
`buffer_publisher_response` drives `stream_publisher_body`, which ignores
`params.dispatched_auction` — so they injected an empty `tsjs.bids = {}`
while Fastly (legacy streaming finalize) served real bids.

- Add `buffer_publisher_response_async` in core: for the Stream variant it
  drives `stream_publisher_body_async`, which awaits
  `collect_dispatched_auction`, writes `ad_bids_state`, and injects the bids
  before `</body>`.
- Pass the configured `creative_opportunities.slot` (not empty) to
  `handle_publisher_request` on all three adapters; it matches them against
  the request path internally.
- Call the async finalize from each adapter (Cloudflare/Spin via their
  now-async `resolve_publisher_response`).

EID targeting stays off for now (these adapters pass `kv: None`).
…ters

The auction consent gate (`consent_allows_server_side_auction`) reads
jurisdiction and TCF consent from the EC context. The adapters passed
`EcContext::default()` to `handle_publisher_request`, leaving jurisdiction
Unknown with no consent — so the gate failed closed and no auction ran
(empty `tsjs.bids`), even though the slots matched.

Build the context via `read_from_request_with_geo` (consent from the
request, geo from the platform), mirroring the Fastly entry point, and fall
back to default on a parse error. Cloudflare resolves geo from the Workers
`cf` object when deployed; Axum and Spin have no-op geo providers, so on
those a known non-GDPR jurisdiction requires the request to carry geo or the
gate needs a TCF consent signal.
When the auction does not run, this pinpoints which gate suppressed it
(slots, bot, navigation, consent, or orchestrator kill switch) instead of
only seeing `dispatch_auction: None`. Pair with the EC-context jurisdiction
log when consent_allows_auction is false.
The EdgeZero buffered path passed empty slots and finalized via the sync
`buffer_publisher_response`, so configured creative-opportunity slots were
routed to the legacy path by `edgezero_can_handle_settings`. Now that
`buffer_publisher_response_async` collects the dispatched auction, EdgeZero
can run the full ad stack:

- Pass the configured `creative_opportunities.slot` and finalize via
  `buffer_publisher_response_async` (the path's `ec.ec_context` already
  carries consent + platform geo). EID targeting stays off (`registry: None`).
- Drop the `edgezero_can_handle_settings` gate, its routing branch, the three
  tests, and the now-unused test settings helpers — EdgeZero handles
  configured slots, so the legacy fallback for them is obsolete.
The EdgeZero publisher path dispatched the auction with registry: None, so
the bid request carried no KV identity-graph EIDs (only client cookie EIDs).
It already passes ec.kv_graph as the identity KV, so wire the matching
PartnerRegistry::from_config(settings.ec.partners) into the AuctionDispatch
to resolve server-side partner EIDs — matching the legacy auction path.

Fastly-only: the sync EC identity graph (KvIdentityGraph/EcKvStore) works on
Fastly's sync KV; the async-KV portability adapters are unaffected (they
still pass registry: None until the EC graph supports async stores).

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary (fifth review pass)

Re-review at head 59683ac5. Every fourth-pass finding is resolved (tokio → dev-deps, SPA currentPath rollback, slot-resolution prefix collision, atype narrow, injectAdmIntoSlot resolver, u32::MAX format bound, build-stub deny_unknown_fields, silent-bidder-drop → log, build_bid_index collision → log, empty-div_id guard, </body> no-op → warn, EID/device block extracted, stale docs). The core (auction math, consent gating, build/runtime validation parity, XSS escaping) is solid.

This round wires the server-side auction into all adapters (Axum, Cloudflare, Spin, Fastly EdgeZero). The three blocking findings are the same shape: /auction, /__ts/page-bids, and Set-Cookie cache-privacy were not brought to parity across platforms. Inline comments carry the pinpointable non-blocking items; cross-adapter and context-line findings are below.

CI note: checks are currently pending on this push (not yet green) — not a code finding, just flagging that the automated gates haven't completed.

Blocking

🔧 wrench

  • POST /auction is fail-closed-dead on Axum, Cloudflare, and Spin — all three call handle_auction(..., &EcContext::default(), ...) (axum/app.rs:371, cloudflare/app.rs:416, spin/app.rs:509). Default → jurisdiction Unknownconsent_allows_server_side_auction returns false → the endpoint always returns no-bid, even for consented users. The fallback publisher path on each adapter (and both Fastly paths) correctly builds EC context via read_from_request_with_geo. (The EcContext::default() at these lines predates this PR, but this PR fixed the fallback path on the same adapters, leaving /auction as the lone un-fixed entry point for the identical auction.) Fix: build EC context with read_from_request_with_geo in the /auction handler on all three, mirroring the fallback path a few lines away.
  • GET /__ts/page-bids (+ its OPTIONS→403 CSRF guard) is wired only on the Fastly legacy path — present at fastly/main.rs:1128; absent from Fastly EdgeZero app.rs and all three portability adapters. The shipped GPT JS fetches GET /__ts/page-bids on every SPA pushState/popstate (gpt/index.ts:690); on Axum/Cloudflare/Spin — and on Fastly when the EdgeZero rollout flag is on — it falls through to the publisher fallback, is proxied to origin (404), and SPA server-side re-auction silently doesn't happen. Fix: wire GET + OPTIONS→403 for /__ts/page-bids on the EdgeZero path and the three adapters (reuse handle_page_bids + the same EC-context construction), or document SPA re-auction as unsupported there and gate the JS. When it is wired anywhere, it must ship the OPTIONS→403 guard too.
  • Set-Cookie cache-privacy hardening added only to the Fastly apply_finalize_headers — Fastly middleware.rs:225 has enforce_set_cookie_cache_privacy + the uncacheable-operator-header guard; Axum/Cloudflare/Spin middleware.rs still apply operator response_headers (incl. Cache-Control/Surrogate-Control) with no cookie check. On Cloudflare (a real shared cache), an operator/origin Cache-Control: public on a cookie-bearing response would be served as-is. Blast radius is limited today (portability adapters don't yet emit the EC Set-Cookie on the fallback path), but any origin/filter Set-Cookie is still mis-cacheable. Fix: hoist enforce_set_cookie_cache_privacy + the guard into a shared helper (byte-identical logic over edgezero_core::http::Response) and call it from every adapter's apply_finalize_headers. (This also collapses the note-level duplication that already exists between Fastly's main.rs:1382 and middleware.rs:271.)

Non-blocking (context-line or cross-cutting; pinpointable items are inline)

❓ question

  • Regular-provider parse diverges across paths — the collect path calls provider.parse_response_with_context (orchestrator.rs:353) while run_providers_parallel calls plain parse_response (:544). The PR aligned the mediator parse across paths, but non-mediator providers now differ: a future provider overriding parse_response_with_context gets context on the collect (publisher) path but not on the parallel (/auction, page-bids) path. Switch :544 to parse_response_with_context too, or document that only mediators may override it.

🤔 thinking

  • Collect select-loop lacks the parallel path's in-loop deadline checkorchestrator.rs collect_dispatched_auction unconditionally drains every remaining handle (relying on per-backend transport timeouts), while run_providers_parallel breaks once elapsed >= deadline. If a backend timeout fails to fire promptly, collect can block past A_deadline and extend TTFB. Add the same post-iteration deadline guard (keeping already-collected responses) for symmetry.

🌱 seedling

  • handle_auction buffers the full body before the post-read size checkendpoints.rs:135 into_bytes() materializes the whole body before the > MAX_AUCTION_BODY_SIZE check; the Content-Length pre-check covers honest clients, but a lying/omitted-length chunked body isn't peak-bounded. Use a capped streaming read if the platform allows.
  • spa_hook.test.ts doesn't cover the currentPath rollback — the 4th-pass fix (retry a path after a failed load) has no regression test; a dropped currentPath = previousPath line would still pass. Add a two-navigation test (fail then succeed on the same path).
  • [debug] table header is now uncommented in trusted-server.toml — inert (all flags default false and the two new ones stay commented), but the section header is now live in the checked-in default.
  • No test for the u32::MAX format-dimension rejection — the 4th-pass fix (width = 5_000_000_000 → build error) is untested; add a case to lock it in.

📝 note

  • Dead pub sync buffer_publisher_responsepublisher.rs:465; all four adapters use buffer_publisher_response_async. Being pub, no dead-code warning catches it, and it carries the "ignores dispatched_auction, always-empty bids" footgun. #[deprecated] or remove it.

⛏ nitpick

  • Placeholder Request unwrap_or_else contradicts the downstream debug_assert_eq!publisher.rs:687/:1130 fall back to a default-URI Request that would trip make_collect_context's debug_assert_eq!(uri == MEDIATOR_PLACEHOLDER_URL). Since the URL is a compile-time const the builder never fails; prefer .expect("MEDIATOR_PLACEHOLDER_URL should be a valid URI") (and factor the two copies into one helper).

📌 out of scope

  • Server-side KV EID enrichment is Fastly-only — Axum/Cloudflare/Spin pass None for kv/registry (no KV identity store in their AppState). By design, and client cookie EIDs are still resolved + consent-gated on every adapter, so no adapter forwards unconsented EIDs. Worth a one-line doc note.

CI Status

  • Checks are PENDING at review time (recent push). Not verified green.

Comment thread crates/trusted-server-adapter-axum/src/app.rs Outdated
Comment thread crates/trusted-server-adapter-fastly/src/app.rs Outdated
Comment thread crates/trusted-server-adapter-fastly/Cargo.toml Outdated
Comment thread crates/trusted-server-core/src/auction/types.rs
Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-core/src/integrations/prebid.rs
Comment thread crates/trusted-server-core/src/integrations/aps.rs Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Resolve the fifth code-review pass. The blocking findings were all
cross-adapter parity gaps in the server-side auction:

- Build the geo-aware EC context in the /auction handlers on Axum,
  Cloudflare, and Spin. They passed EcContext::default(), leaving
  jurisdiction Unknown and failing the consent gate closed even for
  consented users. A shared per-adapter build_ec_context helper now
  serves /auction, page-bids, and the publisher fallback, and logs
  (rather than swallows) a malformed-consent read error.
- Wire GET /__ts/page-bids and its OPTIONS->403 CSRF guard on the
  Fastly EdgeZero path and all three portability adapters, reusing core
  handle_page_bids and a shared page_bids_preflight_denied() helper.
  Previously it was Fastly-legacy-only, so SPA re-auction silently fell
  through to the origin on every other path.
- Add trusted_server_core::response_privacy with the Set-Cookie
  cache-privacy downgrade and the uncacheable-operator-header guard, and
  call it from every adapter's apply_finalize_headers so a shared cache
  (Cloudflare) can no longer serve an operator/origin public Cache-Control
  on a cookie-bearing response.

Also address the inline and non-blocking findings: warn on a dropped
dispatched auction for bodiless responses, extract build_slot_json shared
by the initial-page and page-bids paths, use creative_opportunity_slots()
everywhere, drop the PBS id->ad_id fallback, log APS slot-id collisions,
align the parallel provider parse with the collect path, remove the dead
sync buffer_publisher_response, factor the mediator placeholder request,
drop the unused toml dependency, guard MediaType against a future
serde(default), and document the Fastly-only KV EID enrichment.

JS: dedup win/billing beacons across concurrent renders, add the SSR
guard to installSlimPrebidLoader, and short-circuit waitForSlotElements
on an already-aborted signal. Add regression tests for the currentPath
rollback and the u32::MAX format-dimension rejection.

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review:

Review Summary

Reviewed PR #680 at 9c0c4249133029c1ee03fbcb562ba98dbac7cdc3 against main, focusing on the current server-side ad-template implementation, auction/privacy gates, cross-adapter route parity, and GPT/Prebid browser flows after the latest fix commits. I did not find any new blocking correctness, security, data-loss, authorization, or severe compatibility issues beyond the existing review threads.

Findings

P0 / Blockers

None.

P1 / High

None.

P2 / Medium

None newly reported in this pass.

P3 / Low

None newly reported in this pass.

CI / Existing Reviews

gh pr checks 680 reports all current checks passing, including fmt, clippy/test jobs, integration/browser tests, vitest, CodeQL, and formatting checks. I also reviewed the existing review/comment history and avoided re-posting duplicate inline feedback; recent author replies indicate the previously raised adapter parity, cache-privacy, page-bids, EID, and several JS race/cleanup findings have been addressed at the current head.

prk-Jr added 2 commits July 1, 2026 21:15
Adopt main's ts-CLI config model over the branch's build-time embedding:
drop the build.rs config generation and [build-dependencies], accept the
trusted-server.toml deletion, remove the now-orphaned creative_slot_build_check
module, and migrate the [creative_opportunities] example into
trusted-server.example.toml. Slot validation is preserved via
Settings::prepare_runtime, which the CLI runs at config push time.

Adopt main's refactored EdgeZero finalize path in the Fastly adapter and
centralize the per-user Set-Cookie cache-privacy guard in send_edgezero_response
so it covers every EdgeZero send path.

Fix three core test helpers for main's Body::into_bytes -> Option change.

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

💯 Approving to keep this moving, and leaving the findings inline using the CREG emoji guide. The inline 🔧 notes are the follow-ups I would address before relying on these paths heavily in production.

Comment thread crates/trusted-server-core/src/integrations/prebid.rs
Comment thread crates/trusted-server-core/src/integrations/aps.rs Outdated
Comment thread crates/trusted-server-core/src/auction/orchestrator.rs
Comment thread crates/trusted-server-core/src/creative_opportunities.rs
Comment thread crates/trusted-server-core/src/response_privacy.rs
Comment thread crates/trusted-server-js/lib/src/integrations/prebid/index.ts Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts Outdated
prk-Jr added 2 commits July 2, 2026 23:31
- Stop forwarding client-supplied X-Forwarded-For to Prebid Server;
  synthesize it from the platform-attested client IP instead
- Make the APS slot ID remapping request-scoped by threading the
  AuctionRequest through parse_response_with_context, removing the
  shared provider-instance mutex that concurrent auctions could race
- Guard dispatch_auction against multi-provider fan-out on platforms
  whose HTTP client executes requests sequentially, mirroring
  run_providers_parallel
- Reject negative and non-finite creative-opportunity floor prices at
  config validation
- Re-run the Set-Cookie cache-privacy downgrade after operator response
  headers are applied so configured Set-Cookie plus public cache
  headers cannot produce a shared-cacheable response
- Install Prebid user ID modules immediately when the bundle loads
  after window.load (slim-Prebid lazy path), with a once-only listener
- Drop allow-same-origin from debug ADM fallback iframes and validate
  extracted iframe src schemes to http(s) only

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary (sixth review pass — approving)

Re-review at head a92e7b24. This round is a clean response to the fifth-pass review: all three blocking cross-adapter findings and every non-blocking item are resolved, CI is green across all adapters, and no new blocking issues were found. Approving with a set of optional follow-ups.

Fifth-pass blocking findings — all resolved ✅

  • POST /auction fail-closed-dead on Axum/Cloudflare/Spin → all four adapters now build a geo-aware EC context via read_from_request_with_geo (build_ec_context/build_ec_request_state) before handle_auction; covered by cross-adapter parity.rs.
  • GET /__ts/page-bids + OPTIONS→403 wired only on Fastly legacy → now wired on the Fastly EdgeZero path and Axum/Cloudflare/Spin, reusing the shared handle_page_bids and the core same-origin/X-TSJS-Page-Bids gate + page_bids_preflight_denied().
  • Set-Cookie cache-privacy hardening Fastly-only → hoisted into the new shared response_privacy.rs (two-pass enforce, surrogate stripping, operator-header guard) and called from every adapter's finalize path. The module is well-tested and portable (wasm32-unknown-unknown safe).

Fifth-pass non-blocking findings — all addressed ✅

Parse-path divergence aligned (parse_response_with_context on both paths); collect-loop deadline documented; body-size an explicit tested 413/BadRequest contract; MediaType default warned against #[serde(default)]; build_slot_json extracted; bodiless-auction log::warn! added; dead sync buffer_publisher_response removed; placeholder-request factored into mediator_placeholder_request() with .expect(...); JS beacon double-fire closed via an in-flight renderingAdIds gate; currentPath rollback test added; installSlimPrebidLoader window guard + waitForSlotElements abort short-circuit added. The +362 APS rework correctly migrated off per-instance Mutex state to context-based build_slot_id_map, with a new out-of-order concurrency regression test and a duplicate-slotID collision log — no new bugs.

Optional follow-ups (non-blocking)

🤔 thinking

  • Cloudflare doesn't register non-primary methods on named routes to the publisher fallbackcloudflare/app.rs:340-548. Axum/Spin/Fastly loop every publisher_fallback_method onto each named route; Cloudflare omits it, so POST /__ts/page-bids, HEAD /auction, etc. return a router-level 405 on Cloudflare vs proxied on the other three. The three security routes (auction POST, page-bids GET, page-bids OPTIONS) are all explicitly registered, so this is not security-affecting — but it's a genuine cross-adapter routing inconsistency from the named-route refactor. Mirror the per-route fallback loop or document the difference.
  • (inline) SPA currentPath rollback to an aborted intermediate path — gpt/index.ts:740.

🌱 seedling — coverage & robustness

  • No /__ts/page-bids route tests on Axum/Cloudflare/Spin — neither the GET route nor the OPTIONS→403 reject is asserted per-adapter (only Fastly does). Wiring is verified-correct by reading and the gate is tested in core, so this is coverage, not a defect. Add a parity.rs assertion that OPTIONS /__ts/page-bids is 403 on every adapter.
  • The concurrent render-bridge double-fire fix (renderingAdIds) is untestedad_init.test.ts:~917. The existing test resolves the first fetch before dispatching the second same-adId message, so it exercises the persistent firedBeacons dedup, not the new in-flight gate. Add a test with a deferred fetch stub that dispatches two same-adId messages before resolving, asserting one fetch and two beacons.
  • No u32::MAX format-dimension rejection test for the build-time bound.
  • Streaming-body rejection in handle_auction is incidentalendpoints.rs:135 relies on empty-parse-failure of a Body::Stream; the pinned edgezero rev now exposes Body::into_bytes_bounded(max) for an explicit bounded read that would survive AdRequest ever gaining defaultable fields.
  • (inline) Dispatch guard keys off configured provider count — orchestrator.rs:782.
  • APS advertises Video in supports_media_type but to_aps_request sends only Banner sizesaps.rs:702 vs :365; a video-only slot is sent with empty sizes. Pre-existing (identical at base), out of scope for this PR — worth a separate follow-up.

♻️ / 📝 / ⛏ minor

  • (inline) Duplicated 403 construction in the page-bids GET gate — publisher.rs:1988.
  • firedBeacons doc key is stale (says slotId|bidIdentity, actual key is 4-part slotId|bidIdentity|kind|url) — core/types.ts:73.
  • Comment "Reject oversized bodies before any allocation" overstates the core guard — the Fastly adapter already fully buffers the body in compat.rs before the handler; soften to "before core buffers/parses" — endpoints.rs:113.
  • Stale comment referencing the removed sync buffer_publisher_responsefastly/app.rs:721.
  • (inline) tokio still a dev-dep for two #[tokio::test]s that could use block_onendpoints.rs:559.

CI Status

  • fmt / clippy / cargo test (all adapters incl. cross-adapter parity) / vitest / CodeQL / integration / EdgeZero integration: all PASS.

Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts Outdated
Comment thread crates/trusted-server-core/src/auction/orchestrator.rs
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/auction/endpoints.rs
prk-Jr added 2 commits July 7, 2026 13:22
- Roll SPA page-bids navigation `currentPath` back to the last applied
  path instead of the immediately-previous one, so an aborted-then-failed
  navigation can no longer strand a route behind the no-op guard; add a
  regression test.
- Add a concurrent render-bridge test: two same-adId messages before the
  cache fetch resolves must collapse to one fetch (in-flight gate), two beacons.
- Assert `OPTIONS /__ts/page-bids` is denied with 403 on every adapter
  (Axum/Cloudflare/Spin) in cross-adapter parity.
- Add a `u32::MAX` banner-format test covering the imp-drop branch when all
  formats exceed `i32::MAX`.
- Dedup the page-bids GET 403 into `page_bids_preflight_denied()`.
- Fix stale comments/docs: `buffer_publisher_response_async`, soften the
  oversized-body comment, and correct the `firedBeacons` key doc.
Resolve #744 (legacy entry-point removal + streaming publisher) against the
server-side auction: keep the buffered auction path on all adapters, supersede
the streaming publisher for publisher navigation. Verified: fastly/core/spin/
axum/cloudflare tests + clippy + fmt clean.
@aram356 aram356 merged commit c058a57 into main Jul 7, 2026
19 checks passed
@aram356 aram356 deleted the server-side-ad-templates-impl branch July 7, 2026 20:08
ChristianPavilonis pushed a commit that referenced this pull request Jul 7, 2026
- Fix examnple.com typo in sourcepoint.rs test fixture
- Guard SPA navigation race: check inflight controller identity after await res.json() before writing __ts_ad_slots/__ts_bids
- Extract build_empty_bids_script() helper; html_processor.rs now calls it instead of duplicating the inline literal
- Add invariant comment to unreachable None branch of prepend_auction_debug_comment
- Cap parse_ts_eids_cookie to 32 eids / 32 uids per eid; log and return None when exceeded
- Add #[serde(deny_unknown_fields)] to openrtb::Eid and Uid
- Add #[serde(deny_unknown_fields)] to CreativeOpportunitiesFile and CreativeOpportunitySlot
- Log debug message when adserver_mock crid does not match <bidder>-creative convention
- Skip zero-dimension bids in adserver_mock with debug log
- Fail closed in APS parse_aps_slot on malformed size string instead of producing 0x0 bid
prk-Jr added a commit that referenced this pull request Jul 8, 2026
Main squash-merged the same server-side ad-templates feature (#680),
then added Tinybird auction telemetry (#818), edgezero v0.0.4 pins
(#862), and external first-party Prebid bundle loading (#743).

Resolutions:
- Files where this branch matched the impl branch tip take main's
  version (telemetry/prebid-bundle additions on top of identical
  feature code): orchestrator, endpoints, openrtb, prebid integration,
  fastly app, .gitignore, prebid docs
- creative_opportunities.rs keeps this branch's version (adds the
  shared ad-stack gate and pattern machinery used by the ts CLI;
  main's copy was identical minus those additions)
- publisher.rs takes main's version with this branch's
  should_run_server_side_ad_stack delegation to
  evaluate_ad_stack_gate re-applied
- run.rs keeps both sides' CLI tests (ad-templates/audit + prebid
  bundle)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants