Register APS renderer via requestId so it survives Prebid field stripping - #963
Register APS renderer via requestId so it survives Prebid field stripping#963aram356 wants to merge 10 commits into
Conversation
…ping APS bids are bid-by-reference: interpretResponse sets the renderer descriptor on the Prebid bid as the custom `trustedServerRenderer` field, and a bidResponse listener registers it in window.tsjs.apsPrebidRenderers keyed by Prebid's generated adId so the Universal Creative can later request it. Prebid normalizes each bid into its own object during addBidResponse and drops unknown top-level fields, so the custom field can be gone before the bidResponse listener runs (observed in production: absent as early as bidAccepted). The listener then saw renderer === undefined and returned without registering, leaving the registry empty; the Universal Creative's request found nothing and Prebid's default renderer threw "Missing ad markup or URL" (reason noAd) for every APS bid. Also stash the descriptor keyed by `requestId` (a first-class field Prebid preserves) when the bids are built, and have the bidResponse listener fall back to that stash when the custom field is absent. Additive: the existing field path is unchanged, so cases where the field survives behave exactly as before; the fallback engages only when Prebid has stripped it. Bounded map. Adds a unit test that registers via requestId with the custom field removed.
auction/orchestrator.rs:
- Combined imports (http::Request + std::collections::{HashMap, HashSet}).
- Kept main's post-launch backend-name collision defense and resolved-name
correlation on both parallel and sequential dispatch, while preserving #918's
per-provider effective_timeout in the backend_to_provider 4-tuple (declarations
and read sites already expect the 4th element).
- Took main's test provider fields (configured_timeout_ms / predicted_timeouts)
and its DivergentBackendProvider; updated a #918 test stub to the merged struct
shape (configured_timeout_ms: 125 to preserve its capped-launch-timeout assertion).
publisher.rs:
- Advertise the configured publisher_domain in the page URL (main's fix; the edge
Host must not leak into the bid request per the in-code comment) using #918's
request_path_and_query field (the field the merged MatchedSlotsContext exposes);
updated a stale test literal accordingly.
Verified: cargo check (axum) clean; orchestrator + build_auction_request tests pass.
…aps-renderer-requestid-registration
d6caf2f to
b00655e
Compare
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
The requestId fallback is directionally correct, but this PR is not safe to merge in its current state. The stacked base was rebased: GitHub reports the PR as conflicting, the merge base is stale, and the displayed change set has expanded to 43 files rather than the intended two-file fix. The head omits current-base commits covering publisher streaming, inline SSAT rendering, APS security review fixes, and rebased CI fixes; the final-tree comparison removes approximately 1,426 lines from streaming_processor.rs alone.
Please recreate or rebase this branch from the current origin/issue-764-aps-openrtb, then cherry-pick or reapply only 7406ed7d. Confirm that the resulting PR contains only the intended Prebid implementation and test changes, and rerun the complete CI gates. The currently green integration checks predate the latest base commits and do not validate the conflicting merge result.
| }) | ||
| } | ||
|
|
||
| fn debug_headers(headers: &HeaderMap) -> BTreeMap<String, Vec<String>> { |
There was a problem hiding this comment.
🔧 The stale head reintroduces client-visible leakage of all APS upstream response headers
debug_headers copies every upstream response header into APS debug metadata, which is returned to page JavaScript. This can expose Set-Cookie, identity headers, internal tracing data, or authentication-related metadata whenever APS debugging is enabled. The current base intentionally fixes this with a fail-closed allowlist containing only Content-Type.
Please preserve that allowlist while rebasing. Any additional debug headers should be explicitly reviewed and redacted rather than copied wholesale.
| } | ||
| // Prefer the custom field; fall back to the requestId stash when Prebid has stripped | ||
| // it during bid normalization (the field is often gone before this listener runs). | ||
| const renderer = bid[APS_RENDERER_FIELD] ?? takePendingApsRenderer(bid['requestId']); |
There was a problem hiding this comment.
🔧 Pending renderer descriptors are not consumed when the custom field survives
Because ?? short-circuits, takePendingApsRenderer is never called when trustedServerRenderer survives normalization. The descriptor stashed earlier therefore remains until FIFO eviction. A renderer envelope may contain up to 349,528 base64 characters, so retaining 256 entries can preserve roughly 85 Mi characters on a long-lived publisher or SPA page; it also leaves stale descriptors available if a request ID is reused.
Consume the pending value before selecting the primary/fallback renderer:
| const renderer = bid[APS_RENDERER_FIELD] ?? takePendingApsRenderer(bid['requestId']); | |
| const pendingRenderer = takePendingApsRenderer(bid['requestId']); | |
| const renderer = bid[APS_RENDERER_FIELD] ?? pendingRenderer; |
Please also add a regression test that registers through the surviving custom-field path, then sends a field-stripped event with the same request ID and verifies that no stale renderer is registered again.
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
The root cause is well diagnosed and the fix is the right shape: requestId is a first-class field Prebid preserves, the fallback is additive, and the new test simulates the actual stripping rather than asserting on internals. Three things block merge: the PR cannot currently be merged into its base, the descriptor scrub was quietly narrowed to the success path, and the stash key is per-adUnit rather than per-bid so multiple APS bids on one imp lose all but the first renderer.
Note on review surface: the substantive change is one commit and two files (crates/trusted-server-js/lib/src/integrations/prebid/index.ts and its test). The other 41 files in the GitHub diff are an artifact of the base branch having been rebased under this branch — see the first finding.
Blocking
🔧 wrench
- PR is unmergeable into its base: GitHub reports
mergeable: CONFLICTING/mergeStateStatus: DIRTY.issue-764-aps-openrtbwas rebased after this branch merged an older copy of it, so a test merge conflicts in 8 files:auction/orchestrator.rs,integrations/aps.rs,integrations/prebid.rs,publisher.rs,browser/tests/shared/aps-renderer.spec.ts,gpt/index.ts,prebid/index.ts,prebid/index.test.ts. Both files this PR actually changes are in that list, and the base'sbidResponselistener still has the pre-fix shape, so the conflict resolution is not mechanical. Merge the current base in and re-verify the fix still applies on top of it. - Descriptor scrub became conditional:
delete bid[APS_RENDERER_FIELD]now only runs when registration succeeds, so a failed registration leaves the descriptor on the Prebid bid object (prebid/index.ts:579). Inline comment has the detail. - Stash key collides on multiple APS bids per imp:
requestIdis per-adUnit, andtakePendingApsRendererdeletes on read, so only the firstbidResponsefor a given imp registers (prebid/index.ts:254). Inline comment has the detail.
Non-blocking
♻️ refactor
- Consider
bid.metaover a module-globalMap:metais first-class, already populated by this adapter, and preserved throughaddBidResponse— it would remove the stash, the cap, the eviction, and the key collision above (prebid/index.ts:204).
🤔 thinking
- Stash lifetime: entries are left behind both when the custom field survives (
??short-circuits beforetake) and when Prebid rejects a bid before delivery. Bounded by the 256 cap, but clearing onauctionEndwould scope the map to the auction it belongs to (prebid/index.ts:564).
👍 praise
- Root-cause writeup and test design: the commit message pins the failure precisely (field absent as early as
bidAccepted, not merely atbidResponse), and the test removes the custom field to reproduce stripping instead of asserting on the stash internals.registerApsPrebidRendererre-validating the descriptor (aps/render.ts:210) also means the new path inherits full validation rather than trusting the stash — good layering.
CI Status
Four checks ran and all pass: browser integration tests, integration tests, integration tests (Fastly EC lifecycle), prepare integration artifacts.
The repository's main gates — cargo fmt, the six clippy-* targets, the four cargo test-* targets, the parity suite, and the JS build/test/format jobs — did not run on this PR, since it targets issue-764-aps-openrtb rather than main. Given the change is JS-only, please confirm npx vitest run and npm run format in crates/trusted-server-js/lib were run locally against the post-merge tree.
| advertiserDomains: bid.adomain, | ||
| const requestId = origReq?.bidId ?? bid.impid; | ||
| // Stash by requestId so registration survives Prebid stripping the custom field. | ||
| if (renderer) stashPendingApsRenderer(requestId, renderer); |
There was a problem hiding this comment.
🔧 wrench — Stash key collides when one imp gets multiple APS bids.
requestId here is origReq?.bidId ?? bid.impid — a per-adUnit value, not per-bid. parseAuctionResponse flattens every seatbid[].bid[] (src/core/auction.ts), so two or more APS bids for the same impid map to the same requestId. The second stashPendingApsRenderer call overwrites the first, and takePendingApsRenderer deletes on read — so the first bidResponse consumes the single entry and the second bid registers nothing.
If the auction winner is that second bid, the slot renders empty and Prebid throws Missing ad markup or URL — the exact failure this PR fixes. The old custom-field path did not have this collision because each normalized bid object carried its own copy.
Fix: key the stash per bid rather than per request, or store a list per requestId:
// per-bid key
const rendererKey = `${requestId}|${bid.creativeId}|${index}`;
if (renderer) stashPendingApsRenderer(rendererKey, renderer);…which requires the key to be reconstructible in the bidResponse listener, so a list-per-requestId (shift on read) is likely the smaller change.
| markRendered: () => markBidAsRendered(rawBid), | ||
| } | ||
| ); | ||
| if (registered) { |
There was a problem hiding this comment.
🔧 wrench — The descriptor scrub silently became conditional.
Before this PR the listener deleted bid[APS_RENDERER_FIELD] unconditionally, immediately after the registration attempt. Now the delete only runs on the success branch, so a descriptor that fails registration stays attached to the Prebid bid object and travels onward into targeting and analytics consumers.
That contradicts the comment directly below it ("Keep the executable capability only in the bounded, one-time registry") and the PR description's claim that the existing custom-field path is unchanged.
Fix: restore the unconditional scrub.
const registered = registerApsPrebidRenderer(/* … */);
delete bid[APS_RENDERER_FIELD];
if (!registered) {
log.warn('[tsjs-prebid] rejected APS renderer capability that failed registration');
}| // stashed here keyed by `requestId` (a first-class field Prebid preserves) at | ||
| // `interpretResponse` time, and the `bidResponse` listener falls back to it. Bounded. | ||
| const MAX_PENDING_APS_RENDERERS = 256; | ||
| const pendingApsRenderersByRequestId = new Map<string, unknown>(); |
There was a problem hiding this comment.
♻️ refactor — bid.meta may remove the need for module-global state entirely.
meta is a first-class Prebid bid field that this adapter already populates a few lines down (meta: { advertiserDomains: bid.adomain }) and that Prebid preserves through addBidResponse — the same property requestId is being relied on for here. Carrying the descriptor as meta.trustedServerRenderer would eliminate this Map, the 256-entry cap, the FIFO eviction, the take-on-read lifecycle, and the key-collision problem flagged on the stash call below.
Worth confirming against the same live deployment used to validate this fix before committing to module-global state, since the stripping behaviour was observed empirically rather than derived from Prebid's contract.
| } | ||
| // Prefer the custom field; fall back to the requestId stash when Prebid has stripped | ||
| // it during bid normalization (the field is often gone before this listener runs). | ||
| const renderer = bid[APS_RENDERER_FIELD] ?? takePendingApsRenderer(bid['requestId']); |
There was a problem hiding this comment.
🤔 thinking — Two stash-lifetime leaks, both bounded but avoidable.
- When the custom field does survive,
??short-circuits and the stashed copy for thatrequestIdis never taken — it lingers until FIFO eviction. Taking unconditionally (or deleting after a successful registration) would keep the map tight. - Bids Prebid rejects before delivery (below floor, size filtered, invalid) never reach this listener at all, so their descriptors are also held until eviction.
Neither is unbounded, but clearing the map on auctionEnd would collapse both cases and make the map's lifetime match the auction it belongs to.
Fixes #962. Stacked on #918 (targets
issue-764-aps-openrtb).Problem
APS bid-by-reference renderers never register, so every APS bid renders empty and Prebid throws
Missing ad markup or URL(reasonnoAd).interpretResponsesets the renderer descriptor on the bid as the customtrustedServerRendererfield; thebidResponselistener registers it intowindow.tsjs.apsPrebidRendererskeyed by the generatedadId. But Prebid drops unknown top-level fields when it normalizes bids duringaddBidResponse, so the custom field is gone before the listener runs (absent as early asbidAccepted). The listener seesrenderer === undefined, returns, and the registry stays empty.Fix (minimal, additive)
Stash the descriptor keyed by
requestId— a first-class field Prebid preserves — when the bids are built, and have thebidResponselistener fall back to that stash when the custom field is absent.interpretResponse(auctionBidsToPrebidBids):if (renderer) stashPendingApsRenderer(requestId, renderer)bidResponselistener:const renderer = bid[APS_RENDERER_FIELD] ?? takePendingApsRenderer(bid['requestId'])Map(256 entries);takedeletes on read.Additive by design: the existing custom-field path is unchanged, so cases where the field survives (e.g. unit/browser harness) behave exactly as before. The fallback engages only when Prebid has stripped the field. No new dependency; the bridge, descriptor format, server, and render path are untouched.
Verification
requestId.noAdfailures dropped sharply, and APS creatives rendered in-slot instead of erroring.