Skip to content

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

Open
prk-Jr wants to merge 148 commits into
mainfrom
server-side-ad-templates-impl
Open

Implement server-side ad slot templates with PBS and APS auction#680
prk-Jr wants to merge 148 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 4 commits June 23, 2026 18:01
Resolve conflicts from the EdgeZero PR #257 sync on main (#761):

- Cargo.toml: adopt main's crate renames, fastly/log-fastly 0.12, and
  edgezero tracking the upstream main branch; keep the branch's glob dep.
- publisher.rs: keep both sides' new tests; forward-port test body
  extraction to the Option-returning Body::into_bytes API and drop the
  now-unused response_body_string helper superseded by the branch tests.
- auction/endpoints.rs: unwrap_or_default the Option-returning into_bytes.
- Relocate the new GPT SPA tests into the renamed crates/trusted-server-js
  tree and refresh stale crates/js doc-comment paths.
- Take main's CI-validated integration-tests lockfile (deps unchanged on
  the branch).
The EdgeZero sync (#761) renamed crates/js and crates/integration-tests
to crates/trusted-server-*. The old directories still hold local-only
build artifacts (node_modules, target, dist) whose gitignore rules moved
with the rename, so git now sees them as untracked. Ignore the defunct
paths until the directories are removed from disk.
P1 — EdgeZero finalize cache/Set-Cookie privacy parity:
Share the protected finalizer between the legacy and EdgeZero paths.
apply_finalize_headers now strips surrogate cache headers and downgrades
cookie-bearing responses to private, and skips operator response_headers
that would re-enable shared caching on uncacheable responses;
finalize_response delegates to it. The EdgeZero entry point re-applies an
HttpResponse enforce_set_cookie_cache_privacy after ec_finalize_response
and request-filter effects so a late EC Set-Cookie cannot reach a shared
cache. Adds middleware tests for both cases.

P1 — empty page-bids must not enable GPT services:
adInit() only enables GPT services when it has a slot to display or
refresh, and the SPA hook skips adInit() for an empty page-bids response
unless prior TS state needs sweeping. Prevents a consent-denied or
kill-switched navigation from activating the publisher's GPT setup.

P2 — scope Prebid refresh targeting to the refreshed slots:
setTargetingForGPTAsync is called with the synthetic refresh ad-unit
codes so a one-slot refresh no longer mutates unrelated GPT slots.

P2 — validate nested slot value shapes at build time:
The creative-slot build check now validates media_type against the
runtime MediaType variants, targeting as a string map, page_patterns as
strings, providers.aps.slot_id as a string, providers.prebid.bidders as a
map, and floor_price as a number — closing build-green/runtime-broken
gaps. A drift-guard test ties media_type to the runtime enum.

CI — suppress CodeQL cleartext-logging false positives:
Annotate the provider/mediator "not registered" warnings; they log static
config identifiers, not secrets.
The merge took main's trusted-server-integration-tests Cargo.lock, but the
branch's trusted-server-core now pulls in glob (the creative-slot build
check uses glob::Pattern). The integration crate path-depends on core, so
its locked graph was missing glob and the --locked CI build refused to
update it. Add only glob v0.3.3; no other versions change, keeping the
shared direct-dependency parity check green.

@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 5a835c13 against origin/main, focusing on the server-side ad-template auction paths, PBS/APS consent forwarding, streaming collection, JS GPT/Prebid integration, and EdgeZero compatibility. CI is green, but I found two high-confidence blocking issues that can leak/omit consent context in PBS calls or violate the configured auction hold deadline. I also left one non-blocking compatibility concern for the EdgeZero path.

Findings

P0 / Blockers

None.

P1 / High

  1. cookies_only Prebid consent forwarding can drop KV-backed consentcrates/trusted-server-core/src/consent/mod.rs:124, crates/trusted-server-core/src/integrations/prebid.rs:877, crates/trusted-server-core/src/integrations/prebid.rs:1085

    • Issue: build_consent_context can satisfy the local server-side auction gate from persisted KV consent when the request has no consent cookies/signals, but cookies_only sets consent_ctx = None, so user.consent, user.ext.consent, and regs are omitted from the OpenRTB body. If the incoming request also lacks a Cookie header, copy_request_headers forwards no consent cookie either.
    • Why it matters: The edge can allow an auction based on KV-backed consent, then send PBS a request carrying user/device signals with no consent signal at all. That is a privacy/compliance regression in the server-side auction path.
    • Suggested fix: In cookies_only mode, include body consent whenever no consent cookie is actually forwarded / the consent source is KV, or fail closed for Prebid in that case. Add a regression test with no incoming consent cookie, KV-backed consent allowing the auction, and consent_forwarding = "cookies_only".
  2. Dispatched auction collection can hold </body> past A_deadline while materializing slow provider bodies — see inline comment.

P2 / Medium

  1. EdgeZero mode silently loses the server-side ad-template feature — see inline comment.

P3 / Low

None.

CI / Existing Reviews

gh pr checks 680 reports all checks passing (cargo fmt/test, clippy/analyze, vitest, browser/integration tests, CodeQL, JS/docs formatting). I reviewed existing comments and avoided re-reporting previously fixed GPT refresh, page-bids origin-gate, win-beacon, timeout-payload, and slot-validation findings. The KV-backed cookies_only issue is in the review body because the exact consent-forwarding lines are outside the final PR diff hunk and GitHub would not accept an inline comment there.

Comment thread crates/trusted-server-core/src/auction/orchestrator.rs
Comment thread crates/trusted-server-adapter-fastly/src/app.rs Outdated

@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.

Refactor follow-up

Checked the existing review threads before submitting: the auction-request assembly refactor is already covered by an earlier comment, so I did not repeat it. These two are separate low-priority maintainability comments.

Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-js/lib/src/integrations/prebid/index.ts

@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 (fourth review pass)

Re-review at head ae17b45a. Every round-3 finding is resolved — build/runtime validation parity for div_id and nested slot fields (with recursion + tests), collect-path observability (parse + transport attribution now mirror the parallel path), slim_prebid_url </script> escaping, the popstate same-path guard, initial-load resilience, per-pattern glob warnings, and the stale-doc/comment fixes. The new EdgeZero empty-config gate is correct and well-tested (defers to the legacy path when slots are configured; no panic or mis-route).

Two new blocking items are small one-line/small fixes; the rest are build/runtime parity residuals, diagnosability, and doc cleanups. Most carry inline comments; a few line-unanchorable items are below.

Blocking

🔧 wrench

  • tokio is a production dependency in trusted-server-core — used only by #[tokio::test]; links the tokio runtime into the wasm prod build. Move to [dev-dependencies]. Cargo.toml:48 (inline).
  • SPA currentPath advanced before fetch, never rolled back on failure — a transient /__ts/page-bids error permanently strands that route (guard blocks retry). gpt/index.ts:681 (inline).

Non-blocking (line-unanchorable; details for the rest are inline)

🌱 seedling

  • sanitizeAuctionUid reads uid.atype (unknown) without a number narrowprebid/index.ts:273. tsc --noEmit flags TS18046/TS2322, but the CI gates (esbuild + eslint + vitest) don't type-check, so it slips through. Fix: if (typeof uid.atype === 'number' && Number.isInteger(uid.atype) && uid.atype >= 0 && uid.atype <= 255).
  • </body> bids/adInit() injection silently no-ops if lol_html's end_tag_handlers() returns Nonehtml_processor.rs (the body end-tag handler). Happens for an implicitly-closed/EOF <body>; adSlots is injected at <head> but bids/adInit() never fire and ads silently never render. Add a log::warn! on the None branch so the whole-feature failure is diagnosable.

📝 note

  • Stale PublisherResponse::Stream docpublisher.rs:337 claims Stream covers HTML only when "no HTML post-processors are registered," but classify_response_route ignores that and routes all processable HTML to Stream (post-processors run inside the streaming processor; the route_streams_non_html_even_with_post_processors_registered test confirms). Drop the post-processor clause. (Related to the dead-param nitpick inline at :401.)

CI Status

  • cargo fmt: PASS
  • cargo clippy: PASS
  • cargo test: PASS (1564 core tests)
  • vitest (JS): PASS (172 tests)
  • CodeQL / integration / browser-integration / EdgeZero integration: PASS

Comment thread crates/trusted-server-core/Cargo.toml Outdated
Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Comment thread crates/trusted-server-core/src/creative_slot_build_check.rs
Comment thread crates/trusted-server-core/build.rs
Comment thread crates/trusted-server-core/src/integrations/prebid.rs
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-adapter-fastly/src/middleware.rs Outdated
Comment thread crates/trusted-server-core/src/auction/types.rs
Comment thread crates/trusted-server-js/lib/src/integrations/gpt/index.ts
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
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
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.

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