Implement server-side ad slot templates with PBS and APS auction#680
Implement server-side ad slot templates with PBS and APS auction#680prk-Jr wants to merge 148 commits into
Conversation
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
…m slotRenderEnded
- 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
…nities.toml at startup
… 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/**
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.
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.
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
left a comment
There was a problem hiding this comment.
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
-
cookies_onlyPrebid consent forwarding can drop KV-backed consent —crates/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_contextcan satisfy the local server-side auction gate from persisted KV consent when the request has no consent cookies/signals, butcookies_onlysetsconsent_ctx = None, souser.consent,user.ext.consent, andregsare omitted from the OpenRTB body. If the incoming request also lacks aCookieheader,copy_request_headersforwards 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_onlymode, 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, andconsent_forwarding = "cookies_only".
- Issue:
-
Dispatched auction collection can hold
</body>pastA_deadlinewhile materializing slow provider bodies — see inline comment.
P2 / Medium
- 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.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
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.
aram356
left a comment
There was a problem hiding this comment.
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
tokiois a production dependency intrusted-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
currentPathadvanced before fetch, never rolled back on failure — a transient/__ts/page-bidserror permanently strands that route (guard blocks retry).gpt/index.ts:681(inline).
Non-blocking (line-unanchorable; details for the rest are inline)
🌱 seedling
sanitizeAuctionUidreadsuid.atype(unknown) without anumbernarrow —prebid/index.ts:273.tsc --noEmitflags 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'send_tag_handlers()returnsNone—html_processor.rs(the body end-tag handler). Happens for an implicitly-closed/EOF<body>;adSlotsis injected at<head>butbids/adInit()never fire and ads silently never render. Add alog::warn!on theNonebranch so the whole-feature failure is diagnosable.
📝 note
- Stale
PublisherResponse::Streamdoc —publisher.rs:337claims Stream covers HTML only when "no HTML post-processors are registered," butclassify_response_routeignores that and routes all processable HTML to Stream (post-processors run inside the streaming processor; theroute_streams_non_html_even_with_post_processors_registeredtest 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
…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
left a comment
There was a problem hiding this comment.
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
- Non-Fastly adapters silently disable configured slot templates and lack the SPA page-bids endpoint —
crates/trusted-server-adapter-axum/src/app.rs:174- Issue: Axum, Cloudflare, and Spin all build an
AuctionDispatchwithslots: &[](cloudflare/src/app.rs:303,spin/src/app.rs:605have the same pattern), and none of those adapters registersGET/OPTIONS /__ts/page-bids. At the same time the shared GPT integration can still installinstallSpaAuctionHook(), which fetches/__ts/page-bidson 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-bidsGET 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.
- Issue: Axum, Cloudflare, and Spin all build an
P2 / Medium
-
Slim-loaded Prebid misses user-ID module initialization after
window.load—crates/trusted-server-js/lib/src/integrations/prebid/index.ts:928- Issue: The GPT slim loader appends the Prebid script from a
window.loadhandler. When that deferred script executes,window.loadhas already fired, but Prebid self-init only schedulesinstallUserIdModules()via anotherwindow.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 whendocument.readyState === 'complete'; otherwise register the load listener with{ once: true }.
- Issue: The GPT slim loader appends the Prebid script from a
-
Raw
</bodybyte scanning can hold the stream on non-tag text —crates/trusted-server-core/src/publisher.rs:900- Issue:
BodyCloseHoldBufferstarts 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.
- Issue:
-
Prefix-based slot lookup can misattribute overlapping dynamic div IDs —
crates/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 asad-andad-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
divToSlotIdmap). Add an overlapping-prefix regression test.
- Issue: After exact lookup fails,
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.
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
left a comment
There was a problem hiding this comment.
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 /auctionis fail-closed-dead on Axum, Cloudflare, and Spin — all three callhandle_auction(..., &EcContext::default(), ...)(axum/app.rs:371, cloudflare/app.rs:416, spin/app.rs:509). Default → jurisdictionUnknown→consent_allows_server_side_auctionreturns 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 viaread_from_request_with_geo. (TheEcContext::default()at these lines predates this PR, but this PR fixed the fallback path on the same adapters, leaving/auctionas the lone un-fixed entry point for the identical auction.) Fix: build EC context withread_from_request_with_geoin the/auctionhandler on all three, mirroring the fallback path a few lines away.GET /__ts/page-bids(+ itsOPTIONS→403 CSRF guard) is wired only on the Fastly legacy path — present atfastly/main.rs:1128; absent from Fastly EdgeZeroapp.rsand all three portability adapters. The shipped GPT JS fetchesGET /__ts/page-bidson every SPApushState/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: wireGET+OPTIONS→403 for/__ts/page-bidson the EdgeZero path and the three adapters (reusehandle_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 theOPTIONS→403 guard too.- Set-Cookie cache-privacy hardening added only to the Fastly
apply_finalize_headers— Fastlymiddleware.rs:225hasenforce_set_cookie_cache_privacy+ the uncacheable-operator-header guard; Axum/Cloudflare/Spinmiddleware.rsstill apply operatorresponse_headers(incl.Cache-Control/Surrogate-Control) with no cookie check. On Cloudflare (a real shared cache), an operator/originCache-Control: publicon a cookie-bearing response would be served as-is. Blast radius is limited today (portability adapters don't yet emit the ECSet-Cookieon the fallback path), but any origin/filterSet-Cookieis still mis-cacheable. Fix: hoistenforce_set_cookie_cache_privacy+ the guard into a shared helper (byte-identical logic overedgezero_core::http::Response) and call it from every adapter'sapply_finalize_headers. (This also collapses the note-level duplication that already exists between Fastly'smain.rs:1382andmiddleware.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) whilerun_providers_parallelcalls plainparse_response(:544). The PR aligned the mediator parse across paths, but non-mediator providers now differ: a future provider overridingparse_response_with_contextgets context on the collect (publisher) path but not on the parallel (/auction, page-bids) path. Switch:544toparse_response_with_contexttoo, or document that only mediators may override it.
🤔 thinking
- Collect select-loop lacks the parallel path's in-loop deadline check —
orchestrator.rscollect_dispatched_auctionunconditionally drains every remaining handle (relying on per-backend transport timeouts), whilerun_providers_parallelbreaks onceelapsed >= 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_auctionbuffers the full body before the post-read size check —endpoints.rs:135into_bytes()materializes the whole body before the> MAX_AUCTION_BODY_SIZEcheck; 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.tsdoesn't cover thecurrentPathrollback — the 4th-pass fix (retry a path after a failed load) has no regression test; a droppedcurrentPath = previousPathline would still pass. Add a two-navigation test (fail then succeed on the same path).[debug]table header is now uncommented intrusted-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::MAXformat-dimension rejection — the 4th-pass fix (width = 5_000_000_000→ build error) is untested; add a case to lock it in.
📝 note
- Dead
pubsyncbuffer_publisher_response—publisher.rs:465; all four adapters usebuffer_publisher_response_async. Beingpub, no dead-code warning catches it, and it carries the "ignoresdispatched_auction, always-empty bids" footgun.#[deprecated]or remove it.
⛏ nitpick
- Placeholder
Requestunwrap_or_elsecontradicts the downstreamdebug_assert_eq!—publisher.rs:687/:1130fall back to a default-URIRequestthat would tripmake_collect_context'sdebug_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
Noneforkv/registry(no KV identity store in theirAppState). 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.
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
left a comment
There was a problem hiding this comment.
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.
Summary
[creative_opportunities]intrusted-server.toml. Matching slots are selected from the incoming document URL at the edge, andwindow.tsjs.adSlotsis injected at<head>open so initial GPT setup does not need a separate slot-discovery request.window.tsjs.bidsbefore</body>.window.tsjs.adInitGPT runtime path. It readswindow.tsjs.adSlotsandwindow.tsjs.bidssynchronously, defines or reuses GPT slots, applies slot-level andhb_*targeting, setsts_initial=1, refreshes the initial slots, and firesnurl/burlonly afterslotRenderEndedconfirms the TS bid won viahb_adid.[integrations.prebid].suppress_nurl_bidders, while retaining the deployment-wide[integrations.prebid].suppress_nurlcompatibility switch.window.tsjs.adSlots/ GPT metadata instead of placeholder refresh sizes.ts-eidscookie written by TSJS, gates EIDs by consent, and forwards them as OpenRTBuser.ext.eidsto PBS.ClientInfointo the server-side PBS request so bidders see the browser client context rather than the Fastly edge context.GET /__ts/page-bidshook, which updateswindow.tsjs.adSlots/window.tsjs.bidsand re-runswindow.tsjs.adInit()afterpushState,replaceState, orpopstatenavigations.What the server-side auction sends to PBS
user.idts-eccookie or generated EC identityuser.consent/user.ext.consentuser.ext.eidsts-eidscookie plus EC identity resolutionuser.ext.ec_freshdevice.uaUser-Agentheaderdevice.ipClientInfo.client_ipdevice.geodevice.dnttrueif setDNT: 1headerdevice.languageAccept-Languageheadersite.domain/site.pagesite.refRefererheaderregs.gdpr/regs.us_privacy/regs.gppimp.*[creative_opportunities]slot templates and Prebid configtmax[creative_opportunities].auction_timeout_ms, falling back to[auction].timeout_msChanges
trusted-server.toml[creative_opportunities]slot templates and documents Prebid nurl suppression knobs..env.example/ docsSUPPRESS_NURL_BIDDERS.crates/trusted-server-core/src/creative_opportunities.rscrates/trusted-server-core/src/settings.rs/build.rstrusted-server.toml.crates/trusted-server-core/src/publisher.rswindow.tsjs.adSlotsandwindow.tsjs.bids, applies cache-control safeguards, handles page-bids responses, and forwards client context.crates/trusted-server-core/src/html_processor.rstsjs.bidsinjection.crates/trusted-server-core/src/auction/*crates/trusted-server-core/src/integrations/prebid.rsnurl/burlpropagation, and per-bidder suppression.crates/trusted-server-core/src/integrations/aps.rscrates/trusted-server-core/src/integrations/adserver_mock.rscrates/trusted-server-core/src/integrations/gpt.rs/gpt_bootstrap.jswindow.tsjs.adInitbootstrap path.crates/js/lib/src/integrations/gpt/index.tswindow.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.tscrates/trusted-server-adapter-fastly/src/main.rsCloses
Closes #677
Closes #697
Closes #698
Closes #699
Closes #700
Closes #702
Test plan
Automated
cargo test --workspacecargo clippy --workspace --all-targets --all-features -- -D warningscargo fmt --all -- --checkgit diff --checkcd crates/js/lib && node build-all.mjscd crates/js/lib && npm run lintcd crates/js/lib && npm run formatcd docs && npm run formatcd crates/js/lib && npx vitest runcurrently fails before test discovery in this workspace withERR_REQUIRE_ESMfromhtml-encoding-snifferrequiring@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>openExpected: an array of slot objects. Each entry has
id,gam_unit_path,div_id,formats, andtargeting. Note thediv_idvalue from one matching slot for step 3.Step 2 - Verify server-side auction results are injected before
</body>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.adInitwired GPT targetingReplace
SLOT_DIV_IDwith thediv_idfrom step 1.Expected: the slot has the configured GAM unit path and targeting includes
hb_pb,hb_bidder, any slot-level keys such aspos/zone, andts_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.bidsis 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.bidsassignment 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
unwrap()in production codelogmacros instead ofprintln!