Skip to content

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

Open
prk-Jr wants to merge 126 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 126 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.

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

Re-review of PR #680 at head 4d4fb1b2. The prior review's findings are substantially resolved with tests — beacon over-firing, runtime slot-id validation, the SPA-hook coverage gap, the leaked global in the JS tests, page-bids path normalization, and the redundant constructors are all fixed. Two new, well-isolated bugs remain (one-line/one-function fixes), plus parity and cleanup items. Inline comments carry most findings; cross-cutting items that don't pin to a changed line are below.

Blocking

🔧 wrench

  • price_bucket truncates instead of roundingprice_bucket.rs:24 (inline). Under-buckets common CPMs (0.29→"0.28", 1.15→"1.14", …) that become hb_pb targeting keys.
  • Sync mediation path drops bid metadata (nurl/burl/ad_id)auction/orchestrator.rs:269. run_auction → run_parallel_mediation parses the mediator response with the context-less parse_response, while the async collect path correctly uses parse_response_with_context (:1015). adserver_mock::parse_response rebuilds an empty BidIndex, so nurl/burl/ad_id are dropped — and that function's own comment falsely claims "the orchestrator always calls parse_response_with_context." Reachable via the POST /auction endpoint. Fix: change the one call site to parse_response_with_context(platform_resp, response_time_ms, &mediator_context) (the context is already constructed in scope).

Non-blocking (not pinnable to a single changed line)

🤔 thinking

  • Dispatched auction silently dropped on Buffered/PassThrough routespublisher.rs:1342-1398. should_run_auction is decided from request signals before the origin content-type/status/encoding is known. A navigation that already dispatched SSP bid requests but then routes to BufferedUnmodified (non-2xx, unsupported encoding e.g. zstd, empty host) or PassThrough (2xx non-HTML) drops the DispatchedAuction without collect_dispatched_auction — the in-flight partner requests are wasted and the pending handles left uncollected. Not a crash, but wasted SSP quota and lost bids. At minimum log::warn! when dispatched_auction.is_some() on those arms (ideally await-and-discard the result).

🌱 seedling

  • Sync mediator timeout not capped at timeout_msauction/orchestrator.rs:243. run_parallel_mediation gives the mediator the full remaining auction budget, while the collect path tightens it to remaining.min(mediator.timeout_ms()). Not a correctness bug (remaining budget is still a valid bound), but the two paths differ; apply .min(mediator.timeout_ms()) for symmetry.
  • Coverage gap: env-injected slot-id rejection untested — there is a TOML-path test (settings_rejects_invalid_creative_opportunity_slot_id) but none asserting TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"bad id",…}]' is rejected via from_toml_and_env / build-time validate_creative_slot. Adding one locks in the regression that the runtime-validation fix addressed.

📝 note

  • /20** still used as the primary doc example for a path globcreative_opportunities.rs (~:161). That string is an invalid glob that only "works" via the *** normalization fallback; using it as the canonical example invites copy-paste of broken config. Prefer a valid example (e.g. "/2024/*"), keeping the normalization note as the edge-case caveat.

Prior-findings resolution (round 1)

Resolved: beacon over-fire/double-fire; validate_slot_id dead at runtime; JS test addEventListener leak + duplication; installSpaAuctionHook coverage; Prebid stored-request fan-out (documented intentional); page-bids path normalization; Cache-Control intent; collect-loop deadline check; redundant dense()/banner() constructors; main.rs cookie-path cache guard; POST /auction consent gate.

Still open: cookies.rs::parse_ts_eids_cookie dead code (♻️, inline); APS provider Mutex state (🤔, inline — adserver_mock was migrated, APS was not).

CI Status

  • cargo fmt: PASS
  • cargo clippy: PASS
  • cargo test: PASS
  • vitest (JS): PASS
  • CodeQL / integration / browser-integration: PASS

prk-Jr added 11 commits June 16, 2026 09:26
…path

run_parallel_mediation parsed the mediator response through parse_response,
which (for adserver_mock) drops nurl/burl/ad_id and PBS cache fields restored
only in parse_response_with_context. The synchronous mediation path used by
POST /auction and /__ts/page-bids could therefore return mediated cache bids
without hb_adid / cache metadata, breaking creative rendering and win/billing
beacons even though the dispatched collect path preserves them.

Call parse_response_with_context with the mediator context (which carries the
collected SSP responses), matching the dispatched collect path. Add a
regression test proving a mediated bid keeps its restored nurl/ad_id through
run_auction.
In the fallback path where Trusted Server defines a GPT slot itself, the code
called defineSlot().addService() then refresh(), but never googletag.display()
for the new slot. GPT requires a display() call to register/render a slot, so
TS-owned first-impression slots no-op ("defineSlot was called without a matching
display call") and miss impressions. Reused publisher-owned slots are unaffected
because the publisher already displayed them.

Track TS-defined slot element IDs separately, display() them once after services
are enabled, and keep refresh() for reused publisher-owned slots only. Mirror the
change in the inline gpt_bootstrap.js. Add Vitest coverage for the TS-owned
display path and keep the refresh-bypass test on a reused slot.
The build-time validator only checked for a non-empty page_patterns string, so a
config like page_patterns = ["["] passed the release build and then failed
settings load at runtime when compile_patterns rejected the slot. Compile each
pattern with the same glob::Pattern::new + ** -> * normalization contract used by
the runtime compile_patterns, requiring at least one pattern that compiles. Adds
glob as a build-dependency and tests for an uncompilable pattern and the
recursive ** case.
finalize_response checked for lowercase "private"/"no-store" substrings, but
Cache-Control directives are case-insensitive (RFC 9111). A Cache-Control:
No-Store on a Set-Cookie response was treated as cacheable and downgraded to the
weaker private, max-age=0, and a Cache-Control: Private did not block operator
response_headers from re-enabling shared caching. Lowercase the header value
before matching. Add mixed-case No-Store / Private tests.
The comment recommends a 500ms default because the value bounds the
DOMContentLoaded/window.load slip, but the checked-in value was 1500ms, so a
first rollout that enables slots while inheriting the default would impose a
1.5s close-body hold on cache-hit pages. Set the sample default to 500ms.
Many two-decimal CPMs are not exactly representable in binary floating point:
0.29 * 100.0 is 28.999…, so flooring truncated it to 28 ("0.28"), and 1.15
became "1.14". These values feed hb_pb targeting keys, so the auction reported a
cent low. Convert CPM to whole cents through a helper that nudges values sitting
an ULP below a cent boundary up before flooring, leaving genuinely sub-cent
values (0.015 -> "0.01") untouched. Adds a float-boundary regression test.
run_parallel_mediation gave the mediator the full remaining auction budget,
while the dispatched collect path bounds it by remaining.min(mediator.timeout_ms()).
Apply the same cap for symmetry between the two paths.
should_run_auction is decided from request signals before the origin
content-type/status/encoding is known. A navigation that dispatched SSP bid
requests but then routes to PassThrough (2xx non-HTML) or BufferedUnmodified
(non-2xx, unsupported encoding, empty host) dropped the DispatchedAuction
without collecting it — wasted SSP quota with no visibility. Log a warning on
those arms when an auction was dispatched.
Lock in that a TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT override with an
invalid id is rejected through from_toml_and_env, complementing the existing
TOML-path test and exercising the same validation the build-time check uses.
"/20**" is an invalid glob that only matches via the **->* normalization
fallback; using it as the canonical example invites copy-paste of broken config.
Show "/2024/*" as the primary example and keep the normalization note as the
edge-case caveat.
parse_ts_eids_cookie was gated to #[cfg(test)] and exercised only by its own
tests; production reads the ts-eids cookie through resolve_client_auction_eids
-> parse_prebid_eids_cookie (which enforces its own size/length caps). Remove
the function, its tests, and the now-orphaned cfg(test) imports/helpers.
@prk-Jr

prk-Jr commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Re: review pullrequestreview-4499471630 — blocking body finding.

P1 — synchronous mediated auctions lose restored render/accounting fields. Fixed in c99bac8.

run_parallel_mediation now calls mediator.parse_response_with_context(platform_resp, response_time_ms, &mediator_context).await, matching the dispatched collect path, so nurl/burl/ad_id and PBS cache fields are restored on the synchronous POST /auction and /__ts/page-bids paths (the mediator context already carries the collected SSP provider_responses). Added a regression test, mediated_bid_preserves_restored_fields_through_run_auction, that registers a context-restoring mediator and asserts the winning bid keeps its nurl/ad_id through run_auction. The four inline findings (GPT display, build-time glob validation, case-insensitive Cache-Control, creative timeout default) are answered on their threads.

@prk-Jr

prk-Jr commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Re: review pullrequestreview-4500409866 (second pass) — all items addressed.

Blocking

  • price_bucket under-buckets via float truncation — 3a5c4b4. 0.29 * 100.0 is 28.999…, so flooring truncated to "0.28". CPM→cents now goes through a helper that corrects the binary-float representation error before flooring, so 0.29"0.29" and 1.15"1.15" while genuinely sub-cent values still floor (0.015"0.01", 0.289"0.28"). Pure round-half was avoided because it would break Prebid floor semantics. Regression test added.
  • Sync mediation drops bid metadata — c99bac8 (details in the reply on the other review). The adserver_mock::parse_response comment is now accurate since both mediator paths use parse_response_with_context.

Non-blocking

  • Dispatched auction dropped on Buffered/PassThrough — 64ecc74. Added log::warn! on both arms when dispatched_auction.is_some(). Kept it to a warning (not await-and-discard): the SSP requests are already dispatched so the quota is spent, and those routes have no </body> to inject bids into, so awaiting only adds latency to non-HTML responses.
  • Sync mediator timeout cap — 0f241ca. Now remaining_ms.min(mediator.timeout_ms()), matching the collect path.
  • Env-injected slot-id rejection test — ac0add3. Asserts TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT with an invalid id is rejected via from_toml_and_env; the build-time path uses the same validate_creative_slot.
  • /20** doc example — c324e09. Now /2024/*, with the normalization note kept as the edge-case caveat.

Inline / cleanup

  • parse_ts_eids_cookie dead code — bdb00f5. Removed the #[cfg(test)]-only function, its tests, and orphaned imports. Production parses the cookie through resolve_client_auction_eidsparse_cookie_auction_eidsparse_prebid_eids_cookie, which enforces its own size/length caps, so no behavior or hardening is lost.
  • APS provider Mutex state — deferred. Unlike adserver_mock (which derives its bid index from context.provider_responses), APS's slot_id_map derives from the AuctionRequest, which AuctionContext does not carry; the existing code comment documents this. It is single-threaded-safe (one request per Wasm instance), and migrating it cleanly needs an AuctionProvider/AuctionContext change to thread the request through — out of scope for this PR. Happy to track it as a follow-up.

CI: cargo fmt / clippy -D warnings / cargo test --workspace (1558) / vitest / wasm release all 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: I reviewed PR #680 at bdb00f5 against main. CI is green, but I found one blocking privacy/cacheability issue that should be fixed before merge: EC cookie writes happen after the cache-safety finalizer, so first-visit cookie-bearing responses can retain shared-cache headers. I also found two non-blocking but important issues around refresh bidder params and build-time/runtime config validation. Details are inline.

Comment thread crates/trusted-server-adapter-fastly/src/main.rs Outdated
Comment thread crates/js/lib/src/integrations/prebid/index.ts Outdated
Comment thread crates/trusted-server-core/build.rs
prk-Jr added 4 commits June 17, 2026 13:01
Resolve route_request signature conflict by keeping the creative-opportunity
slots parameter alongside main's mutable request binding. Align the merged-in
DataDome auction protection test with the branch's server-side auction consent
gate by resolving a non-regulated jurisdiction so the empty-provider auction
still reaches orchestration.
finalize_response applies the cookie cache-privacy downgrade on the
HttpResponse, but the EC identity cookie is written later by
ec_finalize_response onto the converted Fastly response. A first-visit
navigation whose only per-user payload is the EC cookie therefore kept any
public/surrogate cache headers from the origin or operator response headers,
so a shared cache could store and replay one visitor's EC cookie to others.

Re-apply the downgrade with enforce_set_cookie_cache_privacy after EC
finalization in both the buffered and streaming branches, mirror it in the
route test helper, and cover the first-visit ordering with route tests.
The synthetic refresh ad unit only carried the trustedServer bid with a zone,
so the requestBids shim had no original server-side bidder entries to collect
into bidderParams. Refresh/scroll /auction requests therefore sent {} for
inline PBS params and dropped demand the publisher configured only on the
initial ad unit.

Recover the matching original pbjs.adUnits server-side params by ad unit code —
from both raw bidder entries and params already folded onto the initial
trustedServer bid — and attach them to the synthetic refresh bid.
The build script types price_granularity as a String and slots as raw JSON
values, so values the runtime schema rejects — a price_granularity outside the
PriceGranularity enum (e.g. custom), or unknown slot keys under the slot's
deny_unknown_fields — embedded cleanly and then failed settings load on every
non-health request, turning a green build into a request-time outage.

Validate price_granularity against the real PriceGranularity enum and reject
unknown top-level slot fields in the shared build-check validator before the
merged config is embedded, with tests for both.

@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: Reviewed PR #680 at 32de4aa against main, focusing on the current server-side ad-template, page-bids, consent/identity, and config paths. CI is green, but I found remaining blocking correctness/privacy issues in the publisher/page-bids flow: EC generation can bypass the adapter's real-browser gate, and /__ts/page-bids can still drive GPT slot creation/refresh when the kill switch or consent gate disables the server-side ad stack. I also left one medium config-safety comment.

CI checked: gh pr checks reports all current checks passing (fmt, clippy/analyze, cargo test, vitest, format docs/typescript, browser/integration tests, CodeQL). I reviewed existing comments and avoided re-reporting resolved historical findings; the page-bids finding is a remaining/incomplete edge of the earlier kill-switch/consent fixes.

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-core/src/creative_opportunities.rs
Stop handle_publisher_request from minting its own EC ID. EC generation
is the adapter's real-browser-gated responsibility; the duplicate inline
call re-ran for any navigation with no real-browser signal, so a
non-real-browser client could get an IP-derived EC minted in memory and
forwarded to PBS/APS even though the adapter blocked EC operations.

Gate /__ts/page-bids slot output on the effective ad-stack condition
(auction kill switch + consent), not just winning bids. Returning slots
while the stack is disabled let the SPA hook run adInit() and create or
refresh GPT slots client-side, defeating the kill switch. This matches
the publisher navigation path's should_run_server_side_ad_stack gate.

Add deny_unknown_fields to the top-level creative-opportunities config
and nested provider/format structs so misspelled keys fail at startup
instead of silently disabling or mis-timing the ad stack.

Add regression tests for all three and update the page-bids tests to
isolate the bot/prefetch variable from the consent gate.

@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: reviewed PR #680 at f456b50 against main. CI is currently green, but I found blocking cache/privacy regressions around late Set-Cookie response effects and surrogate-cache headers, plus one medium refresh-demand correctness issue. Details are inline.

Comment thread crates/trusted-server-adapter-fastly/src/main.rs Outdated
Comment thread crates/trusted-server-adapter-fastly/src/main.rs Outdated
Comment thread crates/js/lib/src/integrations/prebid/index.ts 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.

Review Summary

🔧 Requesting changes on PR #680 at f456b506. CI is green, but I found two high-priority correctness/security issues and one medium config-safety issue in the server-side ad-template flow.

I avoided duplicating the latest existing inline comments on request-filter Set-Cookie cache privacy, no-store surrogate headers, and container-backed Prebid refresh recovery.

Findings submitted inline: 0 P0, 2 P1, 1 P2, 0 P3.

Comment thread crates/trusted-server-core/src/auction/orchestrator.rs Outdated
Comment thread crates/trusted-server-adapter-fastly/src/main.rs
Comment thread crates/trusted-server-core/src/creative_opportunities.rs
Apply request-filter response effects before the final Set-Cookie cache
guard in every Fastly response path (buffered, streaming, asset
streaming) so a per-user cookie added by a DataDome allow can no longer
leave with public/surrogate cache headers. Strip surrogate cache headers
on every Set-Cookie response — even one keeping a stricter no-store
directive — and treat no-store as protected in the operator-header guard.

Reject OPTIONS /__ts/page-bids at the adapter so the side-effecting
endpoint never grants a CORS preflight the publisher origin might.

Drain every dispatched SSP request in the collect loop instead of
breaking on the auction deadline, so a slow origin can no longer discard
SSP responses that already arrived.

Reject empty/whitespace div_id overrides at runtime validation, which
would otherwise bind a slot to the first id-bearing DOM element.

Recover Prebid refresh params and client-side bids from candidate codes
([gpt element id, injected div_id]) so container-backed slots keep the
publisher's configured demand on refresh/scroll auctions.

@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 a3160d5 against main. CI is currently green, and many prior review issues have been fixed, but I found two remaining high-impact issues that should be addressed before merge: /auction can still forward client-provided EIDs when the current consent context forbids EC identity use, and TS-owned GPT slots can stay blank on pages that use GPT disableInitialLoad(). Details are inline.

Review Summary

Reviewed the current server-side ad templates implementation, focusing on consent/privacy, the /auction and /__ts/page-bids paths, GPT/Prebid client behavior, and existing review context. Overall risk is reduced compared with earlier revisions, but these two issues affect privacy/compliance and first-impression serving compatibility.

Findings

P0 / Blockers

None.

P1 / High

  1. /auction resolves and forwards client EIDs even when EC identity is not allowed — see inline.
  2. TS-owned GPT slots are only displayed, not refreshed, so pages using GPT disableInitialLoad() can get blank first impressions — see inline.

P2 / Medium

None included.

P3 / Low

None included.

CI / Existing Reviews

All current GitHub checks pass (cargo fmt/test, clippy/Analyze, vitest, integration/browser tests, CodeQL, docs/TS format). I checked prior review threads and avoided re-reporting issues that were already fixed or explicitly deferred.

// a no-bid response here prevents outbound PBS/APS calls and the forwarding
// of request-derived signals (UA/IP/geo, and cookies under some Prebid
// consent-forwarding modes) for traffic that must not run an auction.
if !consent_allows_server_side_auction(&consent_context) {

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: P1 privacy/security — /auction still forwards client EIDs when EC identity use is denied.

This new auction gate prevents provider calls when GDPR/unknown-jurisdiction consent is missing, and ec_id is correctly gated on ec_context.ec_allowed() above. But immediately below this block, client_eids is still resolved unconditionally from the request body or the ts-eids cookie. In a US/GPC or US-Privacy opt-out context, allows_ec_creation() blocks EC identity use, while consent_allows_server_side_auction() can still allow a non-personalized auction and gate_eids_by_consent() allows EIDs when no TCF signal exists and GDPR does not apply. That lets a caller bypass the identity-consent gate by putting persistent EIDs in the /auction body/cookie, unlike the publisher and /__ts/page-bids paths, which only resolve client EIDs when ec_id.is_some().

Please gate client EID resolution on the same identity-consent condition used for EC IDs (for example, only call resolve_client_auction_eids(...) when ec_id.is_some() / ec_context.ec_allowed()), or strengthen gate_eids_by_consent() so explicit US/GPC identity opt-outs strip EIDs. Add a regression test for /auction with an explicit US opt-out/GPC context and body/cookie EIDs proving the outgoing auction request has user.eids == None.

// called without a matching display call") and misses its impression.
// Must run after enableServices(); on SPA navigation services are already
// enabled, so this runs unconditionally for any newly-defined slots.
slotsToDisplay.forEach((divId) => g.display?.(divId));

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: P1 compatibility/correctness — TS-owned slots can stay blank when GPT initial load is disabled.

For slots Trusted Server defines itself, this path calls display() but never includes those slots in the guarded refresh() below. That works only when GPT initial load is enabled. On common Prebid/GPT integrations, publishers call googletag.pubads().disableInitialLoad() so display() merely registers the slot and the ad request is made by a later refresh(). TS-owned first-impression slots then keep their server-side targeting but never request an ad, so the new server-side template can silently render blank inventory.

Please mirror this in both the bundle and gpt_bootstrap.js: detect/track GPT initial-load-disabled state (e.g. via the GPT API where available, or a wrapper when disableInitialLoad() is called) and, after display(), refresh newly defined slots under the same adInitRefreshInProgress bypass when initial load is disabled. Add a Vitest/bootstrap regression covering disableInitialLoad() with a TS-defined slot.

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