From 77ad391d2de08c362d820d561ba8dda8349fe89a Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 11 Aug 2026 07:26:27 -0500 Subject: [PATCH 1/3] Document config-first auction provider architecture --- ...st-auction-provider-architecture-design.md | 928 ++++++++++++++++++ 1 file changed, 928 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md diff --git a/docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md b/docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md new file mode 100644 index 000000000..413edcecf --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md @@ -0,0 +1,928 @@ +# Config-First Auction Provider Architecture + +**Date:** 2026-08-10 +**Status:** Draft +**Scope:** Configuration-first OpenRTB 2.6 auction providers with Prebid Server and APS parity + +## Summary + +Redesign Trusted Server auction-provider registration around configuration-defined provider instances. + +The auction orchestrator will no longer contain statically configured Prebid Server and APS provider instances. Instead: + +- Provider instances are defined under `[auction.providers.*]`. +- OpenRTB 2.6 is the only protocol implemented in the first version. +- A provider selects an OpenRTB profile that owns nonstandard request and response semantics. +- Trusted Server integrations may register profiles through a compile-time Rust registry. +- A central bidder registry routes each client-requested bidder to exactly one provider. +- Provider configuration is compiled and validated at startup into an immutable auction plan. +- The runtime orchestrator operates only on compiled provider plans and normalized auction data. + +This redesign changes provider registration, routing, request construction, and response normalization. It deliberately preserves existing auction economics, privacy enforcement, signing behavior, creative delivery, mediation, and telemetry unless a structural change is required to support the new provider architecture. + +> **Core principle:** Configuration defines provider instances. Rust code registers +> tested protocols and profiles. A compiler turns configuration into an immutable +> plan. The orchestrator executes that plan without knowing about Prebid Server, +> APS, or any specific exchange. + +## Goal + +Allow operators to register any endpoint that conforms to the supported OpenRTB 2.6 subset without adding a new provider implementation. + +The same architecture must fully replace the current Prebid Server and APS auction providers while preserving their required behavior through OpenRTB profiles. + +## Problem + +The current system models Prebid Server and APS as statically named provider implementations. Each implementation combines several concerns: + +- Provider identity and enablement. +- OpenRTB request construction. +- Provider-specific request extensions. +- HTTP transport and backend registration. +- Timeout handling. +- OpenRTB response parsing. +- Provider-specific validation. +- Creative interpretation. +- Diagnostics. + +This causes several constraints: + +- Only one instance of each statically named provider can be registered. +- Adding a standards-compliant OpenRTB endpoint still requires Rust provider code. +- Provider identity, protocol behavior, and upstream seat identity are not cleanly separated. +- Slot routing is implicit and differs by provider. +- Deploy-time validation and runtime registration maintain separate provider inventories. +- Prebid browser integration concerns are coupled to server-side provider configuration. + +The auction orchestrator itself already has useful behavior for parallel execution, deadlines, partial failures, winner selection, mediation, and telemetry. The redesign should preserve those strengths while replacing provider construction and routing. + +## Design Decisions + +### Configuration-first boundary + +“Configuration-first” means a standards-compliant endpoint within the supported OpenRTB 2.6 subset can be onboarded using configuration alone. + +It does not mean: + +- Arbitrary OpenRTB variants can be programmed through configuration. +- New protocols can be implemented through templates or scripts. +- Provider-specific behavior can use unrestricted JSON transformations. + +If an endpoint requires nonstandard semantics, those semantics are implemented as ordinary Rust profile behavior registered in this repository. + +### Protocol scope + +The architecture may support additional protocols later, but the first version implements only: + +```text +openrtb-2.6 +``` + +No runtime plugin, sandboxed WASM, dynamic native extension, or configuration scripting system is included. + +### Provider configuration location + +All server-side auction-provider instances are configured under: + +```toml +[auction.providers.*] +``` + +Browser and page integrations remain under: + +```toml +[integrations.*] +``` + +A browser integration may be disabled while its server-side auction profile is used by a provider. + +### Profile model + +The first version exposes one profile selection per provider rather than a configurable list of capabilities. + +Profiles are registered Rust implementations with narrow responsibility for protocol-specific request and response semantics. The initial profiles are: + +- `standard` +- `prebid-server` +- `aps` + +Internally, profile implementations may share smaller components. Those components are not exposed as a public capability-composition language in the first version. + +### Current behavior preservation + +The redesign preserves the current behavior of: + +- Trusted Server request signing, using the existing signing protocol. +- Consent extraction, privacy enforcement, and identity gating. +- Highest decoded-price winner selection. +- Current floor enforcement. +- Current USD assumptions. +- Optional external mediation. +- Creative sanitization and delivery. +- APS rendering behavior. +- Prebid Cache handling. +- Existing auction telemetry and provider outcome semantics. + +The project does not redesign these systems. + +### Media scope + +The first version supports banner inventory only. + +Non-banner formats are excluded before provider routing and are never emitted upstream. A slot with no valid banner format is skipped. The canonical model and protocol boundary may remain extensible to video and native, but video and native request construction, validation, ranking, and rendering are outside this specification. + +## Non-Goals + +This specification does not include: + +- A new auction pricing or ranking model. +- Currency conversion or a new money representation. +- Changes to floor behavior. +- Changes to external mediation behavior. +- A new request-signing protocol. +- A new privacy or consent system. +- A new telemetry schema. +- A new creative renderer architecture. +- Video or native media support. +- Multiple routes for the same bidder. +- Request splitting across multiple upstream calls for one provider. +- Runtime-loaded profiles or plugins. +- Sandboxed WASM extensions. +- Arbitrary JSONPath, templates, scripts, or response expressions. +- Speculative endpoint authentication mechanisms. +- Label-based routing, provider groups, or a general routing rule language. +- Arbitrary overrides of standard OpenRTB fields. +- Migration compatibility with the current provider configuration schema. + +Breaking configuration changes are acceptable for this design. + +## Terminology + +### Provider ID + +A unique operator-defined provider instance, such as: + +```text +pbs-primary +aps-primary +rubicon-direct +``` + +Provider health, runtime correlation, configuration, and telemetry use the provider ID. + +### Bidder ID + +A demand source requested by the publisher or browser integration, such as: + +```text +rubicon +pubmatic +appnexus +``` + +Trusted Server maps each bidder ID to one provider ID. + +### Protocol + +The wire contract used by a provider. The only first-version value is `openrtb-2.6`. + +### Profile + +A registered Rust implementation that augments generic OpenRTB request construction and interprets nonstandard response semantics. + +### Seat + +The buyer identity returned in `seatbid.seat`. A seat is not a provider ID and must not be used for transport correlation. + +### Provider plan + +An immutable, validated runtime representation compiled from one provider's configuration. + +## High-Level Architecture + +```mermaid +flowchart TD + Config[Trusted Server configuration] --> Compiler[Auction plan compiler] + Registry[Protocol and profile registry] --> Compiler + Compiler --> Plan[Immutable AuctionPlan] + Request[Canonical AuctionRequest] --> Router[Bidder and slot router] + Plan --> Router + Router --> Inputs[Per-provider ProviderAuctionInput] + Inputs --> Encoder[OpenRTB 2.6 driver and selected profile] + Encoder --> Transport[Existing platform HTTP transport] + Transport --> Decoder[OpenRTB decoder and selected profile] + Decoder --> Outcomes[Normalized provider outcomes] + Outcomes --> Decision[Existing ranking or mediation] + Decision --> Delivery[Existing creative delivery] +``` + +### Control plane + +The control plane parses configuration, registers available profiles, validates provider and bidder references, and compiles an immutable `AuctionPlan` during startup. + +### Runtime plane + +The runtime plane receives a canonical auction request, routes its bidder demand to provider plans, creates one provider-specific input per provider, executes the existing concurrent auction flow, and normalizes responses before the existing decision stage. + +Raw configuration is not repeatedly interpreted during auctions. + +## Configuration Schema + +### Auction configuration + +The provider blocks are the source of truth. A separate ordered provider-name list is not required. + +```toml +[auction] +enabled = true +timeout_ms = 2000 + +[auction.providers.pbs-primary] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://pbs.example/openrtb2/auction" +timeout_ms = 900 +routing = "explicit" + +[auction.providers.aps-primary] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example/bid" +timeout_ms = 700 +routing = "explicit" + +[auction.providers.aps-primary.profile] +account_id = "example-account" +allow_script_creatives = false + +[auction.providers.rubicon-direct] +protocol = "openrtb-2.6" +profile = "standard" +endpoint = "https://rubicon.example/bid" +timeout_ms = 650 +routing = "explicit" + +[auction.providers.rubicon-direct.profile] +request_ext = { account = "example-account" } +imp_ext = { placementGroup = "display" } +``` + +### Common provider fields + +| Field | Required | Default | Meaning | +| ------------ | -------- | --------------- | -------------------------------------------------------------------------- | +| `protocol` | Yes | None | Registered protocol identifier. First version supports only `openrtb-2.6`. | +| `profile` | No | `standard` | Registered OpenRTB profile identifier. | +| `endpoint` | Yes | None | Fixed operator-configured HTTPS endpoint. | +| `timeout_ms` | No | Auction timeout | Maximum provider timeout, capped by the remaining auction deadline. | +| `routing` | No | `explicit` | `explicit` or `all_eligible`. | + +Provider presence under `[auction.providers.*]` means the provider is configured for the enabled auction. The implementation may add a conventional enablement field only if required by the broader settings system; it must not reintroduce a separate provider inventory. + +### Bidder registry + +The central bidder registry maps each client-visible bidder to exactly one provider: + +```toml +[auction.bidders.rubicon] +provider = "rubicon-direct" + +[auction.bidders.pubmatic] +provider = "pbs-primary" + +[auction.bidders.appnexus] +provider = "pbs-primary" + +[auction.bidders.aps] +provider = "aps-primary" +``` + +The client requests bidders. It does not select providers or endpoints. + +### Browser integrations + +Browser-specific behavior remains separate: + +```toml +[integrations.prebid] +# Browser bundle, injection, adapter, and script behavior only. +``` + +Enabling or disabling a browser integration does not register, enable, or disable an auction provider. + +## Configuration Validation + +The auction plan compiler must reject configuration when: + +- A provider ID is duplicated or invalid. +- A protocol is unknown. +- A profile is unknown. +- A profile configuration cannot be parsed or validated. +- A bidder references an unknown provider. +- A bidder has more than one provider route. +- A profile cannot support banner inventory. +- A provider endpoint is invalid or violates existing outbound endpoint requirements. +- A provider's static extension configuration is not an object. +- Static extensions exceed bounded size or nesting limits. +- Static extensions collide with reserved fields owned by the OpenRTB driver, signing, or profile. +- Auction request signing cannot be initialized while auctions are enabled. +- More than one active provider is configured for a platform adapter that cannot perform concurrent fan-out. This target-specific validation is conservative because one auction may request bidders routed to different providers, and any `all_eligible` provider may participate alongside them. + +The same compiler and registry must be used by: + +- Deploy-time configuration validation. +- Runtime startup. +- Provider-plan construction. +- Configuration schema or documentation generation where supported. + +## Profile Registry + +### Registration + +Profiles are registered through ordinary Rust code compiled into Trusted Server. + +Conceptually: + +```text +auction core module → registers "standard" +prebid module → registers "prebid-server" +aps module → registers "aps" +``` + +A profile's availability does not depend on its corresponding browser integration being enabled. + +### Factory responsibility + +A profile factory: + +1. Parses its typed configuration. +2. Validates its configuration. +3. Reports supported media and creative representations. +4. Compiles immutable runtime profile behavior. + +### Runtime responsibility + +A profile may: + +- Augment a generic OpenRTB request within fields reserved to that profile. +- Interpret provider-specific response extensions. +- Apply provider-specific bid validation. +- Produce the existing normalized creative or renderer representation. +- Extract provider-specific metadata required to preserve current behavior. + +A profile must not overwrite fields owned by the OpenRTB driver, central privacy enforcement, or signing. Each profile declares the request extensions and response fields it owns, and the compiler rejects ownership collisions. + +A profile may not: + +- Select its endpoint. +- Send HTTP requests. +- Register platform backends. +- Resolve secrets. +- Route other providers' bidders. +- Rank bids. +- Invoke mediation. +- Override central privacy enforcement. +- Modify another provider plan. + +### Runtime inputs + +A profile receives only: + +- Its compiled profile configuration. +- The provider ID. +- The provider's routed slots and bidder parameters. +- Canonical publisher, user, device, consent, and context data already approved for the auction. +- The effective provider timeout. +- Request-local parse state where required. + +A profile does not receive the raw downstream HTTP request or unrestricted runtime services. Browser headers needed by OpenRTB must be normalized into canonical auction data before profile execution. + +To preserve current Prebid consent-forwarding modes, request admission also produces a bounded, privacy-approved representation containing only the existing allowlisted consent-cookie names and values. The profile selects `openrtb_only`, `cookies_only`, or `both`; common OpenRTB request finalization and transport then apply that mode without exposing the raw browser cookie header to the profile. + +## Canonical Auction Model + +The canonical auction model remains independent of the OpenRTB wire format. + +Conceptually: + +```rust +AuctionRequest { + id, + slots, + publisher, + user, + device, + privacy, + context, +} +``` + +A slot separates requested demand from provider routing and provider-specific input: + +```rust +AuctionSlot { + id, + banner_formats, + floor, + bidder_params, + trusted_provider_routes, +} +``` + +- `bidder_params` is keyed by bidder ID and originates from client or server auction input. +- `trusted_provider_routes` is available only to trusted server-side opportunity construction. +- Client input cannot choose provider IDs directly. + +## Routing Model + +### Client-originated demand + +For each bidder requested on a slot: + +1. Look up the bidder in `[auction.bidders]`. +2. Resolve its single provider ID. +3. Add the slot and only that bidder's parameters to the provider's routed view. +4. Record an `unroutable_bidder` outcome when no route exists. +5. Continue the auction for other routable bidders and providers. + +Example client demand: + +```text +Slot: header +Bidders: rubicon, pubmatic, appnexus +``` + +Configured routes: + +```text +rubicon → rubicon-direct +pubmatic → pbs-primary +appnexus → pbs-primary +``` + +Resulting provider inputs: + +```text +rubicon-direct +└── header + └── rubicon parameters + +pbs-primary +└── header + ├── pubmatic parameters + └── appnexus parameters +``` + +### Trusted server-generated demand + +Server-generated opportunities that intentionally rely on stored requests may name trusted provider routes without supplying bidder parameters. + +This supports the existing Prebid stored-request path without allowing the browser to choose an endpoint. + +### Routing modes + +#### `explicit` + +The provider receives only slots routed through: + +- The central bidder registry. +- Trusted server-generated provider routes. + +This is the default. + +#### `all_eligible` + +The provider receives every banner-compatible slot, regardless of bidder routes. + +This mode must be explicitly configured and exists to preserve use cases similar to the current APS behavior. + +### No eligible slots + +When a provider has no eligible slots: + +- No upstream request is sent. +- The provider is recorded as `skipped_no_eligible_slots`. +- This is distinct from no-bid because the provider was not called. + +## Provider Auction Input + +Routing produces one immutable `ProviderAuctionInput` per provider. + +It contains only: + +- Slots admitted by explicit routes or by that provider's `all_eligible` mode. +- Bidder parameters assigned to that provider through the central bidder registry. +- Privacy-approved canonical auction data. +- Provider identity. +- Effective timeout. + +`all_eligible` admits additional banner slots but does not grant access to bidder parameters assigned to another provider. A profile never receives another provider's bidder parameters and therefore does not need provider-specific exclusion logic. + +## OpenRTB 2.6 Driver + +The generic driver owns standard banner OpenRTB behavior. + +### Request responsibilities + +- Request and impression IDs. +- Banner formats. +- Site and publisher data currently supplied by Trusted Server. +- Device and user data currently supplied by Trusted Server. +- Existing consent and EID forwarding behavior. +- Floors and floor currency. +- `tmax` using the effective timeout. +- Current secure-impression requirements. +- Current auction currency assumptions. +- Existing Trusted Server signing extension, using the existing signing protocol. + +### Response responsibilities + +- HTTP 204 and ordinary empty responses as no-bid where currently supported. +- Standard OpenRTB response decoding. +- Request ID correlation. +- `seatbid.seat` preservation. +- Standard bid ID, impression ID, price, dimensions, domains, creative markup, and notification URLs. +- Current banner compatibility checks. +- Existing response-size bounds. +- Existing error and outcome classifications where applicable. + +### Bidder parameters + +Client-supplied bidder parameters are profile input, not generic OpenRTB fields. + +The generic driver does not invent a location for them. + +- The `prebid-server` profile consumes them. +- Another profile may consume them in a provider-specific way. +- The `standard` profile does not forward nonempty bidder parameters by default. +- Unconsumed nonempty parameters produce bounded `unused_bidder_params` diagnostics. + +### Static extensions + +The standard profile may accept validated static objects for: + +- `request.ext` +- `imp.ext` + +Static extensions: + +- Cannot contain secrets. +- Cannot contain templates. +- Cannot read request data. +- Cannot use JSONPath or arbitrary expressions. +- Are bounded by size and nesting depth. +- Cannot overwrite fields reserved by signing, the OpenRTB driver, or another profile responsibility. + +### Ordinary field overrides + +The first version does not support arbitrary overrides of fields such as `site.domain`, `device.ip`, `user.id`, or `imp.tagid`. + +Typed configuration for additional standard fields should be added only when a concrete endpoint requires it. + +## Prebid Server Profile + +The `prebid-server` profile preserves required Prebid Server behavior while delegating standard fields to the OpenRTB driver. + +### Profile responsibilities + +- Construct `imp.ext.prebid.bidder` from routed bidder parameters. +- Preserve current deterministic bidder-parameter merging and validation semantics where still applicable. +- Support stored-request fallback for trusted server-generated provider routes. +- Add Prebid-specific request extensions and test/debug fields. +- Preserve Prebid Cache coordinate extraction. +- Preserve Prebid response diagnostics required by current behavior. +- Preserve notification suppression behavior through the common provider outcome model. +- Preserve current request-local data needed to parse responses. + +### Responsibilities moved to common architecture + +- Endpoint and timeout configuration. +- Provider identity. +- Bidder routing. +- Standard OpenRTB request fields. +- Trusted Server signing invocation. +- Standard consent and EID forwarding. +- HTTP transport and backend correlation. +- Standard response parsing and validation. +- Winner selection and mediation. + +### Profile configuration parity + +The central bidder registry is the sole server-side bidder allowlist and route source. The Prebid profile does not define a second `bidders` list. + +The first version must preserve these server-side controls and defaults: + +| Current control | New owner | Default and validation | +| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `server_url` | Common provider `endpoint` | Required fixed HTTPS endpoint. | +| `timeout_ms` | Common provider `timeout_ms` | Inherits the auction timeout when omitted. | +| `bidders` | Central `[auction.bidders]` registry | Every bidder route is explicit and unique. | +| `debug` | `auction.providers..profile.debug` | `false`; preserves current request and response debug behavior. | +| `test_mode` | `auction.providers..profile.test_mode` | `false`; preserves the current OpenRTB test flag. | +| `debug_query_params` | `auction.providers..profile.debug_query_params` | Absent by default; preserves current page-URL behavior when configured. | +| `bid_param_zone_overrides` | `auction.providers..profile.bid_param_zone_overrides` | Empty by default; preserves current typed validation and merge behavior. | +| `bid_param_overrides` | `auction.providers..profile.bid_param_overrides` | Empty by default; preserves current typed validation and merge behavior. | +| `bid_param_override_rules` | `auction.providers..profile.bid_param_override_rules` | Empty by default; preserves current rule validation, ordering, and shallow-merge behavior. | +| `consent_forwarding` | `auction.providers..profile.consent_forwarding` | `both`; preserves the existing `openrtb_only`, `cookies_only`, and `both` behavior. | +| `suppress_nurl` | Common `auction.providers..notifications.suppress_all` | `false`. | +| `suppress_nurl_bidders` | Common `auction.providers..notifications.suppress_bidders` | Empty; every entry must name a bidder routed to this provider. | + +Stored-request fallback remains built-in Prebid profile behavior rather than another configuration switch. Existing browser-only fields, including bundle configuration, script patterns, client-side bidders, account injection, and excluded GAM ad-unit suffixes, remain under `[integrations.prebid]`. + +Parity tests must cover defaults and non-default values for every field in this table. + +### Browser integration separation + +The Prebid browser integration continues to own: + +- Browser bundle construction. +- JavaScript injection. +- Browser adapter behavior. +- Client-side bidder configuration. +- Script interception and rewriting. + +It does not own the server-side Prebid provider endpoint or bidder route map. + +## APS Profile + +The `aps` profile preserves APS-specific OpenRTB and rendering behavior. + +### Profile responsibilities + +- Add APS account and SDK request extensions. +- Preserve APS inventory identity behavior. +- Interpret the APS response shape and extension fields. +- Preserve APS-specific bid validation. +- Extract creative URL and tag type. +- Produce the existing typed APS renderer descriptor. +- Preserve script-creative opt-in behavior. +- Preserve APS diagnostics required by current behavior. + +### Responsibilities moved to common architecture + +- Endpoint and timeout configuration. +- Provider identity. +- Bidder routing or explicit `all_eligible` routing. +- Standard OpenRTB request fields. +- Trusted Server signing invocation. +- Standard consent and EID forwarding. +- HTTP transport and backend correlation. +- Winner selection and mediation. + +### Profile configuration parity + +The first version must preserve these APS controls and defaults: + +| Current control | New owner | Default and validation | +| ------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `endpoint` | Common provider `endpoint` | Required fixed HTTPS endpoint; legacy unsupported endpoint forms remain rejected. | +| `timeout_ms` | Common provider `timeout_ms` | Inherits the auction timeout when omitted. | +| `account_id` | `auction.providers..profile.account_id` | Required, nonempty, and subject to the current size and input validation. | +| `debug` | `auction.providers..profile.debug` | `false`; preserves current debug behavior. | +| `allow_script_creatives` | `auction.providers..profile.allow_script_creatives` | `false`; preserves the existing explicit script opt-in. | +| `inventory_domain` | `auction.providers..profile.inventory_domain` | Absent by default; preserves current domain validation. | +| `inventory_page_origin` | `auction.providers..profile.inventory_page_origin` | Absent by default; must be configured with `inventory_domain` and preserve current origin/domain validation. | + +Parity tests must cover defaults, debug behavior, inventory override validation, iframe creatives, permitted script creatives, and rejected script creatives. + +Using the APS profile must activate any server-side renderer support it requires independently of browser integration enablement. + +## Request Signing + +Trusted Server request signing is an auction-wide requirement. + +The project will: + +- Reuse the existing signing implementation and wire contract. +- Avoid cryptographic or protocol redesign. +- Apply existing signing behavior to every OpenRTB provider request. +- Keep signing configuration global rather than repeated under providers. +- Fail startup when auctions are enabled and required signing infrastructure cannot be initialized. + +The exact existing signing payload and verification behavior remain unchanged by this specification. + +## Transport and Execution + +The existing platform transport abstractions remain responsible for: + +- Backend registration and naming. +- Asynchronous request dispatch. +- Response correlation. +- Existing request and response bounds. +- Existing timeout behavior. +- Existing platform-specific fan-out capability checks. + +At most one outbound request is sent per provider per auction. Exactly one request is sent for each provider with eligible slots, containing every slot admitted by its routing mode. + +The first version does not split one provider's slots across multiple requests and does not send one request per slot. + +The existing auction deadline remains authoritative: + +```text +min(provider timeout, auction time remaining) +``` + +Provider failures remain isolated from other provider outcomes. + +Custom endpoint authentication is included only if required by the first concrete generic endpoint. This specification does not define speculative bearer-token, custom-header, or secret-store authentication schemas. + +## Response Normalization + +Every provider response is normalized into the existing shared auction response and bid model, or its clean architectural equivalent. + +The normalized result must preserve: + +- Provider ID. +- Returned seat. +- Slot/impression ID. +- Bid ID. +- Decoded price and existing currency assumptions. +- Banner dimensions. +- Standard creative markup or existing typed renderer. +- Existing notification URL behavior. +- Provider-specific metadata required for current diagnostics. + +Profile-specific response interpretation occurs before bids reach ranking or mediation. + +One provider's malformed response does not fail another provider. Existing behavior for whether an invalid individual bid or full response is dropped should be preserved unless the common driver can enforce an equivalent stricter check without changing externally visible behavior. + +## Decision, Mediation, and Delivery + +This project does not redesign the decision or delivery stages. + +After normalization, the existing system continues to: + +- Select the highest decoded-price bid per slot when no mediator is configured. +- Apply existing floors. +- Use existing USD assumptions. +- Invoke the existing mediator path when configured. +- Fall back according to existing mediation behavior. +- Sanitize and rewrite creatives according to existing settings. +- Serialize standard creatives and APS renderer descriptors according to existing contracts. + +Provider profiles do not rank bids or choose winners. + +## Telemetry and Diagnostics + +Existing auction telemetry and provider result reporting remain in scope for parity. + +The new architecture must preserve the ability to report: + +- Provider instance ID. +- Provider outcome. +- Response time. +- Bid count. +- Returned seats. +- Existing error classifications. +- Winner status. +- Existing profile-specific diagnostics when enabled. + +The redesign may centralize how diagnostics are carried, but it must not introduce a new telemetry product or schema as part of this work. + +New routing outcomes should be distinguishable: + +- `unroutable_bidder` +- `skipped_no_eligible_slots` +- `unused_bidder_params` + +These outcomes use existing bounded diagnostic or outcome fields. They do not add telemetry fields or change the meaning of existing outcomes. They must not include sensitive bidder parameters. + +## Runtime Flow + +```text +1. Receive or construct canonical banner auction request. +2. Apply existing consent, identity, and privacy enforcement. +3. Resolve each client bidder through the central bidder registry. +4. Add trusted provider routes for server-generated opportunities. +5. Build one filtered ProviderAuctionInput per provider. +6. Skip providers with no eligible slots. +7. Use the OpenRTB 2.6 driver to construct the standard request. +8. Invoke the selected profile to augment the request. +9. Apply the existing Trusted Server request signature. +10. Dispatch one request per provider through existing transport. +11. Decode the standard OpenRTB response. +12. Invoke the selected profile for provider-specific normalization. +13. Produce normalized provider outcomes. +14. Run existing local ranking or mediation. +15. Run existing creative delivery and telemetry. +``` + +## Failure Behavior + +The system must preserve partial-auction behavior. + +| Condition | Required outcome | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Unknown bidder in runtime request | Record `unroutable_bidder`; continue other demand. | +| Provider has no eligible slots after applying its routing mode and banner filtering | Record `skipped_no_eligible_slots`; do not call provider. | +| Profile configuration invalid | Reject configuration at deploy/startup. | +| Provider cannot build a valid request | Provider-local launch/build failure. | +| Provider transport fails | Provider-local transport failure. | +| Provider times out | Provider-local timeout. | +| Provider returns valid no-bid | Provider no-bid. | +| Provider response cannot be decoded | Provider-local parse failure. | +| Individual bid is invalid | Preserve current profile/common validation behavior; valid sibling bids remain eligible where currently supported. | +| No providers produce valid bids | Existing auction no-winner behavior. | + +No request with zero eligible impressions should be sent upstream. + +## Security Requirements + +- Provider endpoints are fixed operator configuration, never client-derived. +- Clients cannot select provider IDs or endpoint URLs. +- Each bidder has one server-controlled provider route. +- Endpoint validation must preserve existing HTTPS and backend security requirements. +- Static extensions cannot contain secrets or request templates. +- Profiles cannot bypass central privacy enforcement. +- Profiles cannot access raw browser requests or unrestricted runtime services. +- Provider parameters must not be logged or returned in unbounded diagnostics. +- Existing response-body limits remain enforced. +- Existing creative sanitization remains enforced after winner selection. + +## Acceptance Criteria + +### Configuration and compilation + +- [ ] Provider instances are configured under `[auction.providers.*]`. +- [ ] The first version recognizes only `openrtb-2.6`. +- [ ] `standard`, `prebid-server`, and `aps` profiles are registered through Rust profile factories. +- [ ] Profile availability is independent of browser integration enablement. +- [ ] Provider and profile configuration is compiled once at startup. +- [ ] Deploy validation and runtime startup use the same provider compiler and registry. +- [ ] Duplicate provider IDs, unknown profiles, invalid endpoints, and invalid bidder routes fail validation. +- [ ] Target-specific validation rejects more than one active provider on adapters without concurrent fan-out support. +- [ ] Auction startup fails when required existing signing infrastructure is unavailable. + +### Routing + +- [ ] Clients submit bidder identities and parameters without selecting providers. +- [ ] `[auction.bidders]` routes each bidder to exactly one provider. +- [ ] Unknown runtime bidders are recorded as `unroutable_bidder` without failing other demand. +- [ ] `explicit` is the default provider routing mode. +- [ ] `all_eligible` is available only through explicit provider configuration. +- [ ] Trusted server-generated slots may route directly to providers without inline bidder parameters. +- [ ] Provider inputs contain only slots admitted by the provider's routing mode and only bidder parameters assigned to that provider. +- [ ] `all_eligible` does not expose bidder parameters assigned to another provider. +- [ ] Providers with no eligible slots are skipped without an upstream request. + +### OpenRTB and profiles + +- [ ] The common OpenRTB driver constructs current standard banner request fields. +- [ ] The common driver preserves existing consent, identity, floor, timeout, currency, and signing behavior. +- [ ] Tests prove that standard, Prebid Server, and APS requests apply the unchanged signing protocol after profile request augmentation. +- [ ] Standard OpenRTB endpoints can be configured without adding a provider implementation. +- [ ] Static `request.ext` and `imp.ext` objects are bounded and validated. +- [ ] Client bidder parameters are not assigned an invented generic wire location. +- [ ] The Prebid profile preserves current bidder parameters, stored requests, cache handling, diagnostics, and notification behavior. +- [ ] Prebid parity tests cover `openrtb_only`, `cookies_only`, and `both` using only the canonical allowlisted consent-cookie representation. +- [ ] The APS profile preserves current account extensions, inventory identity, response validation, renderer, script policy, and diagnostics. +- [ ] Prebid and APS no longer register singleton auction-provider instances. + +### Runtime behavior + +- [ ] At most one outbound request is sent per provider per auction, and none is sent when the provider has no eligible slots. +- [ ] Existing concurrent fan-out and timeout behavior remains intact. +- [ ] Provider failures remain isolated. +- [ ] Returned seats remain distinct from provider IDs. +- [ ] Existing winner selection, floors, mediation, creative delivery, and telemetry continue to behave as before. +- [ ] Banner behavior has parity with the existing Prebid and APS paths. +- [ ] Non-banner formats are excluded before routing and are never emitted upstream. +- [ ] A slot with no banner formats is skipped. +- [ ] Video and native are not introduced by this work. + +## Deferred Design Areas + +The following require separate requirements before implementation: + +- Additional protocols. +- Video and native support. +- Multiple provider routes for one bidder. +- Provider groups and label/rule-based routing. +- Request splitting for large auctions. +- New money or currency-conversion models. +- New signing protocol versions. +- New privacy or data-sharing controls. +- New diagnostics and telemetry schemas. +- General endpoint authentication configuration. +- Arbitrary standard-field mappings. +- Runtime or sandboxed profile extensions. + +## Open Questions + +No unresolved product decisions currently block this specification. + +Implementation planning must still identify: + +- The exact Rust profile-factory and compiled-profile interfaces. +- The minimal changes required to separate the current Prebid and APS logic into common OpenRTB and profile-owned behavior. +- The first concrete generic OpenRTB endpoint and whether it requires authentication or additional typed fields. +- The complete parity test fixture set for Prebid, APS, routing, signing, and split dispatch/collect execution. +- The exact configuration representation needed by existing environment override and app-config tooling. + +These are implementation-planning questions and must not expand the product scope defined above. + +## Related Designs + +- [Auction Orchestration Flow](./2026-03-19-auction-orchestration-flow-design.md) +- [Prebid Generic Bid Parameter Override Rules](./2026-04-08-prebid-generic-bid-param-override-rules-design.md) +- [APS OpenRTB First-Class Integration](./2026-07-15-aps-openrtb-first-class-integration-design.md) From 0c98095748a631b2a1a1172a4f8af714fbbc5086 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 11 Aug 2026 18:42:00 -0500 Subject: [PATCH 2/3] Clarify config-first auction provider plan --- ...ovider-architecture-implementation-plan.md | 751 ++++++++++++++++++ ...st-auction-provider-architecture-design.md | 340 ++++++-- 2 files changed, 1003 insertions(+), 88 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-11-config-first-auction-provider-architecture-implementation-plan.md diff --git a/docs/superpowers/plans/2026-08-11-config-first-auction-provider-architecture-implementation-plan.md b/docs/superpowers/plans/2026-08-11-config-first-auction-provider-architecture-implementation-plan.md new file mode 100644 index 000000000..a3c83d4b4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-config-first-auction-provider-architecture-implementation-plan.md @@ -0,0 +1,751 @@ +# Config-First Auction Provider Architecture Implementation Plan + +**Date:** 2026-08-11 +**Status:** Draft for implementation review +**Spec:** `docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md` +**Implementation baseline:** `main` at `af67d8c2`, including merged APS PR #918 + +## Baseline and branch requirement + +The design-spec branch predates the merged APS OpenRTB implementation. Implementation must start from current `main` at or after `af67d8c2`, not from the current documentation branch's Rust tree. Rebase or create a fresh implementation worktree after the specification is merged so the work preserves the current APS OpenRTB request, response, renderer, diagnostics, and parity tests. + +The implementation must not restore the pre-#918 APS `/e/dtb/bid` protocol or use the older encoded-price mediator-only path as its baseline. + +## Decisions locked for this plan + +- The generic `standard` compatibility endpoint is a fictional local mock available only under automated tests. It adds no runtime endpoint, profile, authentication option, or production-support claim. +- Provider ID, returned upstream seat, and browser-facing delivery bidder code are separate identities. + - Provider ID owns configuration, backend correlation, health, and telemetry. + - A valid string `seatbid.seat` is retained as `returned_seat`. + - Existing `Bid.bidder` remains the delivery bidder code for compatibility. + - PBS uses the returned seat or `unknown`; APS continues to use `aps`. +- Disabled global signing removes only signature-bearing fields. PBS retains its existing `request_host`/`request_scheme` object; APS and `standard` omit `ext.trusted_server`. +- Enabled global signing loads one auction-local signer before dispatch and applies the existing version 1.1 extension to every OpenRTB provider after profile augmentation. +- On an adapter with a future enforceable total-request deadline, late completions are timeouts and are discarded. No current adapter claims that capability; an already-launched completed response remains eligible, but no further provider or mediator network work starts after logical budget exhaustion. +- Existing mock mediation remains a statically registered, separately selected path. It is not represented by `ProviderPlan`, a profile, or a new generic mediator trait. +- `BTreeMap` or sorted vectors provide deterministic plan, route, request, and validation order. Runtime correctness must not depend on TOML or `HashMap` iteration order. +- Provider IDs use the spec's lowercase ASCII grammar and 63-byte limit. +- First-version bounds introduced by this work are constants with tests: + - at most 128 `notifications.suppress_seats` entries, each at most 128 UTF-8 bytes; + - at most 128 bidder entries in one `trustedServer.bidderParams` object; + - bidder IDs at most 128 UTF-8 bytes and `trustedServer.zone` at most 256 UTF-8 bytes; + - each static `request_ext` or `imp_ext` object at most 16 KiB serialized, at most eight object/array levels deep, and at most 256 keys at any one object level. +- The existing 256 KiB `/auction` body limit remains authoritative. Header snapshots use the exact first `HeaderMap` value already selected by the request layer and add no truncation behavior. +- No temporary old/new public configuration compatibility mode ships. Internal staging adapters may exist while the branch is under development, but they must be removed before the configuration schema switch is merged. + +## Definition of done + +- `[auction.providers]` and `[auction.bidders]` are the only server-side bidder-provider inventory and routing source. +- `standard`, `prebid-server`, and `aps` are compiled through one Rust registry into an immutable `AuctionPlan`. +- Deploy validation and every adapter startup use the same target-independent compiler; target-aware paths also run the same adapter capability and backend-name validation. +- Runtime request handling uses normalized auction data, a transport-owned Prebid header snapshot, and filtered `ProviderAuctionInput` values. Profiles do not receive the raw HTTP request or unrestricted runtime services. +- A generic OpenRTB 2.6 driver owns standard fields, common response decoding, request finalization, signing, and common notification suppression. +- PBS and APS singleton bidder-provider registration is removed after parity fixtures pass. +- Multiple instances of one profile dispatch and correlate by provider ID. +- Empty trusted Prebid envelopes, explicit bidder routing, APS `all_eligible`, unknown bidders, no-slot skips, and mixed browser demand follow the specification. +- PBS and APS request, response, privacy, creative, timeout, debug, and diagnostics behavior matches `main`, except for the intentional all-provider signing expansion. +- Provider ID, returned seat, and delivery bidder code remain independent through ranking, mediation, delivery, and telemetry. +- Existing ranking, floors, USD assumptions, mock mediation, creative sanitization, APS rendering, Prebid Cache, and telemetry remain intact. +- Example configuration, operator docs, and browser-injected Prebid configuration use the new ownership model. +- All target-specific tests, JS tests, integration parity tests, formatting, and clippy gates pass. + +## Proposed architecture + +### Raw and compiled configuration + +Add raw serde types under `auction_config_types.rs` and compile them into types that cannot represent unresolved references: + +```rust +pub struct AuctionConfig { + pub enabled: bool, + pub timeout_ms: u32, + pub providers: BTreeMap, + pub bidders: BTreeMap, + pub mediator: Option, +} + +pub struct AuctionPlan { + providers: Vec, + bidder_routes: BTreeMap, + signing_enabled: bool, +} + +pub struct ProviderPlan { + id: ProviderId, + endpoint: CanonicalProviderEndpoint, + timeout_ms: u32, + routing: RoutingMode, + notifications: NotificationPolicy, + protocol: ProtocolPlan, + profile: CompiledOpenRtbProfile, +} +``` + +Use newtypes for `ProviderId` and `BidderId`. Sort compiled providers by provider ID and store bidder routes as validated provider indexes or IDs. `AuctionPlan` stores only the enabled signing policy; it never stores loaded keys. + +The first-version protocol representation is a closed `ProtocolPlan::OpenRtb26` enum. Do not introduce scripting, dynamic loading, or a generalized protocol trait before a second protocol exists. + +### Profile registry and dispatch + +Use a small compile-time registration table rather than runtime plugins: + +```rust +pub struct OpenRtbProfileRegistration { + id: &'static str, + default_timeout: ProfileTimeoutDefault, + compile: fn(&serde_json::Value) -> Result>, +} + +enum CompiledOpenRtbProfile { + Standard(StandardProfilePlan), + PrebidServer(PrebidProfilePlan), + Aps(ApsProfilePlan), +} +``` + +The enum supplies typed methods for field policy, request augmentation, request-local parse state, response interpretation, and renderer capabilities. This is intentionally simpler than boxed `Any` state or a public capability-composition system. Adding a repository-owned profile requires a registration entry and enum variant, which is acceptable for the first version. + +Keep PBS-specific compilation and behavior in `integrations/prebid.rs` initially and APS-specific behavior in `integrations/aps.rs`. Expose narrow `pub(crate)` registration/compile hooks to the auction module. Do not convert either large integration file into a directory tree solely for this refactor. + +### Generic provider execution + +Replace singleton bidder providers with one generic planned OpenRTB provider execution path. It owns endpoint selection, backend specification, one-request-per-provider dispatch, common driver invocation, and response association. It dispatches profile behavior through `CompiledOpenRtbProfile` without matching profile names in the orchestrator. + +Adapt the existing `AuctionProvider` dispatch/parse seam rather than replacing the split dispatch/collect mechanism wholesale: + +- change static provider-name APIs to borrow the validated dynamic provider ID; +- let the generic planned provider carry one `Arc`; +- route and filter slots before invoking it; +- retain request-local profile parse state in the existing pending response token; +- split bidder-provider storage from the statically selected mock mediator so the mediator never enters the compiled plan. + +Delete PBS and APS singleton registration only after their planned profiles pass parity tests. + +### Admission and execution context + +Introduce an admitted request representation that separates canonical data from transport-only data: + +```rust +struct AdmittedAuction { + request: AuctionRequest, + prebid_headers: PrebidTransportHeaders, + signer: Option, +} + +struct ProviderAuctionInput { + provider_id: ProviderId, + slots: Vec, + canonical: Arc, + logical_budget_ms: u32, +} +``` + +The exact ownership can use references or `Arc` to avoid copying publisher/user/device data. Profiles receive `ProviderAuctionInput`, never the current raw-request-bearing `AuctionContext`. Common transport receives the private Prebid header snapshot. The existing mediator may continue receiving the broader legacy context until a separate mediation design changes it. + +Keep timeout values explicit and separate: + +- `logical_budget_ms = min(provider_timeout_ms, remaining_auction_ms)` controls launch and OpenRTB `tmax`. +- `transport_timeout_ms = backend.canonicalize_transport_timeout_ms(remaining_auction_ms, provider_timeout_ms)` controls backend identity and the adapter's available transport timers. + +Fastly quantization of `transport_timeout_ms` must never replace or shorten `logical_budget_ms`. + +### Bid identity + +Preserve `Bid.bidder` as the serialized delivery bidder code and add: + +```rust +pub returned_seat: Option +``` + +`AuctionResponse.provider` remains the provider ID. Update comments and constructors so these three identities cannot be accidentally interchanged. Keep `returned_seat` out of unchanged external mediator/client wire shapes where necessary and restore it from the original provider bid after mock mediation. Notification suppression matches only `returned_seat`; response serialization and the APS browser renderer continue to use `Bid.bidder`. The existing telemetry seat carrier uses `returned_seat` when present and falls back to `Bid.bidder` when absent. + +### Target validation + +Add a pure adapter validation description passed into the compiler's second stage: + +```rust +pub struct AuctionTargetCapabilities<'a> { + pub target_name: &'static str, + pub supports_concurrent_fanout: bool, + pub enforces_total_request_deadline: bool, + pub backend_name_predictor: &'a dyn PlatformBackend, +} +``` + +Reuse `PlatformBackend::predict_name` for startup validation. Where CLI validation cannot instantiate the runtime backend, extract one shared pure naming helper/descriptor and make both `predict_name` and CLI validation call it; do not create a second independently implemented codec. Cloudflare and Spin skip registration but still use their real deterministic names containing canonical backend-spec fields and the provider discriminator. + +Target-independent `ts config validate` runs stage one and emits an explicit message that adapter checks are deferred. Adapter startup always runs both stages. + +Target-aware `ts config push --adapter ` must validate before any remote read, prompt, or write. Add an EdgeZero typed-push callback API that receives the already deserialized, environment-overlaid `TrustedServerAppConfig` plus the selected adapter ID between EdgeZero's ordinary typed validation and its first remote operation. Trusted Server maps that ID to the same target capability/name-prediction descriptor used at startup and runs stage two in the callback. Pin the EdgeZero revision containing this hook. This keeps config loading, overlays, platform writes, and adapter selection inside EdgeZero while making target validation mandatory rather than duplicating a loader in Trusted Server. + +## Stage 1 — Pin behavioral parity before refactoring + +Files: + +- `crates/trusted-server-core/src/integrations/prebid.rs` +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/auction/orchestrator.rs` +- `crates/trusted-server-core/src/auction/formats.rs` +- `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +Steps: + +1. Add or consolidate golden helpers that serialize current PBS and APS requests from the same canonical fixture. +2. Pin PBS behavior for: + - standard field ownership differences; + - raw `Referer` in both HTTP forwarding and `site.ref`; + - `User-Agent`, `Accept-Language`, and platform-attested `X-Forwarded-For`; + - every Cookie mode, malformed/non-UTF-8 Cookie fallback, and removal-to-empty; + - TCF, jurisdiction-derived GDPR, USP, GPP/SID, Google Additional Consent, EIDs, and KV/policy body fallback; + - stored requests, bidder-parameter merge precedence, override rules, test/debug fields, Cache coordinates, and response diagnostics; + - exact signing-enabled and signing-disabled JSON, including the disabled host/scheme-only object and absence of every signature-bearing key. +3. Pin APS behavior from merged main for: + - request field differences and account/SDK extensions; + - inventory identity overrides; + - consent and EID placement; + - response shape validation and sibling-bid isolation; + - renderer envelopes and script policy; + - highest-price-per-impression reduction and bid-ID tie-breaking; + - default 800 ms timeout and debug diagnostics; + - signing-disabled absence of `ext.trusted_server` before the intentional coverage expansion. +4. Pin synchronous and split dispatch/collect behavior, including current late completions and mediator fallback. +5. Use only fictional request, endpoint, account, bidder, seat, and creative data. + +This stage establishes the comparison baseline; it must not change production behavior. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +cd crates/trusted-server-js/lib && npx vitest run +``` + +## Stage 2 — Add config-first types, profile registry, and plan compiler + +Files: + +- `crates/trusted-server-core/src/auction_config_types.rs` +- `crates/trusted-server-core/src/auction/mod.rs` +- new `crates/trusted-server-core/src/auction/plan.rs` +- new `crates/trusted-server-core/src/auction/profile.rs` +- internal compiler test modules beside the new plan/profile code + +Steps: + +1. Add `ProviderId`, `BidderId`, `ProviderConfig`, `BidderRouteConfig`, `RoutingMode`, `NotificationConfig`, and typed common validation. +2. Parse profile configuration as an object and immediately hand it to the selected registered profile compiler. Do not retain untyped `serde_json::Value` in the runtime plan. +3. Add the compile-time `standard`, `prebid-server`, and `aps` registration table independent of browser integration enablement. +4. Implement target-independent compilation: + - provider ID grammar and uniqueness; + - only `openrtb-2.6`; + - profile resolution and typed profile config; + - HTTPS endpoint parsing, canonicalization, credentials/fragment rejection, and profile-specific endpoint restrictions; + - profile timeout defaults and explicit overrides; + - bidder-to-provider resolution; + - static extension type, size, depth, key-count, and reserved-field ownership checks; + - notification bounds and duplicate rejection; + - signing configuration structure; + - deterministic provider and route order. +5. Keep `[auction].mediator` validation separate and restricted to the existing static mock mediator. +6. Add compiler tests for every rejection, defaults, two same-profile instances, and browser integration independence. + +This stage is internal scaffolding only: do not replace `AuctionConfig.providers`, change `Settings`, or wire `validate_settings_for_deploy`. Compiler tests construct raw provider maps directly. Public schema and validation switch together in Stage 11, so no committed state exposes dual schemas or makes deploy validation disagree with the live runtime. + +Verification: + +```bash +cargo test-fastly +``` + +## Stage 3 — Add adapter capability and backend-name validation + +Files: + +- `crates/trusted-server-core/src/platform/traits.rs` +- `crates/trusted-server-core/src/platform/types.rs` +- `crates/trusted-server-core/src/integrations/mod.rs` +- `crates/trusted-server-core/src/auction/plan.rs` +- `crates/trusted-server-adapter-fastly/src/backend.rs` +- `crates/trusted-server-adapter-fastly/src/platform.rs` +- `crates/trusted-server-adapter-fastly/src/app.rs` +- `crates/trusted-server-adapter-axum/src/platform.rs` +- `crates/trusted-server-adapter-axum/src/app.rs` +- `crates/trusted-server-adapter-cloudflare/src/platform.rs` +- `crates/trusted-server-adapter-cloudflare/src/app.rs` +- `crates/trusted-server-adapter-spin/src/platform.rs` +- `crates/trusted-server-cli/src/run.rs` +- `crates/trusted-server-cli` target-validation tests +- root EdgeZero dependency pin/lockfile +- coordinated EdgeZero CLI typed-push callback API + +Steps: + +1. Add `AuctionTargetCapabilities` and shared pure backend-name prediction input to target validation. +2. Prepare provider ID as every planned `PlatformBackendSpec.discriminator`; production backend naming switches in Stage 11. +3. Reuse or extract the algorithms behind `PlatformBackend::predict_name` so target validation and runtime construction cannot drift: + - Fastly's canonical backend specification and digest naming; + - Axum's environment/backend normalization; + - Cloudflare's deterministic no-registration backend name; + - Spin's deterministic no-registration backend name. +4. Reject two provider plans whose predicted names collide before either can register or overwrite a correlation map. +5. Declare capabilities from current behavior: + - Fastly: concurrent fan-out, first-byte/between-byte transport timers, and no enforceable total-request deadline; + - Axum: concurrent fan-out and no provider-specific total-request deadline; + - Cloudflare: no concurrent fan-out and no provider-specific total-request deadline; + - Spin: no concurrent fan-out and no provider-specific total-request deadline. +6. Reject more than one active provider for Cloudflare and Spin at target validation. Do not infer that only one will be requested at runtime. +7. Keep existing runtime collision assertions as defense in depth. +8. Add the EdgeZero typed-push validation callback, update the dependency pin, and test that the callback runs after overlays/typed validation but before any remote read or write. +9. Add unit tests that map each CLI adapter ID to the same capability/prediction descriptor used by startup and validate directly constructed plans. End-to-end map-shaped config-push rejection waits for the public schema cutover in Stage 11. +10. Add target prediction/capability tests and same-profile/same-endpoint independent-correlation tests. Actual adapter startup and target-aware push wiring remains part of the atomic Stage 11 cutover. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +``` + +## Stage 4 — Normalize admission and route provider-local inputs + +Files: + +- new `crates/trusted-server-core/src/auction/routing.rs` +- `crates/trusted-server-core/src/auction/types.rs` +- `crates/trusted-server-core/src/auction/formats.rs` +- `crates/trusted-server-core/src/auction/endpoints.rs` +- `crates/trusted-server-core/src/creative_opportunities.rs` +- `crates/trusted-server-core/src/publisher.rs` + +Steps: + +1. Split canonical slot demand into bidder parameters, trusted provider routes, and bounded Prebid `zone` facts. +2. Normalize the reserved `trustedServer` envelope before central routing: + - missing/null/empty `bidderParams` produces stored-request intent; + - non-object, bad key/value, partial malformed, and bound violations reject admission and never fan out stored requests; + - valid unknown bidders route to `unroutable_bidder` and do not trigger fallback; + - usable direct objects win collisions, while unusable direct values cannot overwrite usable envelope objects; + - deterministic merge order is independent of map iteration. +3. Snapshot the first accepted `Cookie`, `User-Agent`, `Referer`, and `Accept-Language` values into a private `PrebidTransportHeaders`. Ignore client-supplied XFF and preserve the platform-attested IP separately. +4. Build one `ProviderAuctionInput` per provider: + - remove non-banner formats before routing; + - `explicit` receives only centrally routed or trusted slots; + - `all_eligible` receives every banner-compatible slot; + - parameters assigned to another provider are never copied; + - no valid banner formats means no provider input; + - no eligible slots produces a skip result without dispatch. +5. Convert creative-opportunity construction: + - explicit bidder parameters remain bidder IDs and use the central registry; + - empty Prebid stored-request intent expands to every compiled PBS plan; + - remove hard-coded APS provider selection and rely on `all_eligible` or explicit central/trusted routing. +6. Use the same admission/router helper for `/auction`, initial navigation, refresh/page-bids, and other server-generated auction entry points. +7. Add table-driven tests for routing, mixed providers, unknown bidders, empty/malformed envelopes, collisions, bounds, non-banner slots, and no parameter leakage. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +``` + +## Stage 5 — Build the common OpenRTB driver and test-only standard profile + +Files: + +- new `crates/trusted-server-core/src/auction/openrtb.rs` +- `crates/trusted-server-core/src/openrtb.rs` +- `crates/trusted-server-core/src/auction/profile.rs` +- `crates/trusted-server-core/src/request_signing/signing.rs` +- `crates/trusted-server-core/src/auction/types.rs` + +Steps: + +1. Extract common banner request construction into a driver that owns: + - request/impression IDs and banner formats; + - site, publisher, user, device, consent, EID, floor, secure, currency, and `tmax` fields; + - application of a typed per-profile field policy; + - common request extension ownership and collision checks. +2. Implement fixed standard/PBS/APS field policies. Treat Stage 1 golden fixtures as normative, especially for consent and privacy differences. +3. Implement `StandardProfilePlan` with bounded static `request.ext` and `imp.ext`. It does not invent a wire location for bidder params. +4. Build response decoding around independent bid validation and preserve current profile-specific whole-response versus sibling-bid behavior. +5. Treat response `id` as informational for PBS/APS parity; keep transport association as the correlation boundary. +6. Add `Bid.returned_seat` while retaining `Bid.bidder` as delivery code. +7. Move signing to common finalization: + - profiles finish augmentation first; + - finalization freezes request ID and signing-owned fields; + - enabled signing inserts the existing v1.1 extension; + - disabled PBS can retain host/scheme only; + - disabled APS/standard omit the object. +8. Add exact serialized signing-on and signing-off fixtures for all three profiles. Assert each signature-bearing key separately: disabled PBS retains only host/scheme, disabled APS/standard omit the object, and every enabled request contains the full v1.1 extension after augmentation. +9. Add a `#[cfg(test)]` fictional standard endpoint mock using the existing stub HTTP-client machinery. Test ordinary bid, no-bid/204, malformed response isolation, static extensions, signed request compatibility, and no-auth behavior. No fixture handler or endpoint is compiled into production. +10. Add a common-transport redirect fixture: return 3xx with `Location`, assert no second request occurs, classify the original provider outcome, and prove the canonical endpoint used for request dispatch is the same one used for backend prediction/construction. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +``` + +## Stage 6 — Build plan-backed orchestrator execution under test construction + +Files: + +- `crates/trusted-server-core/src/auction/provider.rs` +- `crates/trusted-server-core/src/auction/orchestrator.rs` +- `crates/trusted-server-core/src/auction/mod.rs` +- `crates/trusted-server-core/src/auction/endpoints.rs` + +Steps: + +1. Change static provider identity APIs to dynamic validated provider IDs. +2. Add one generic planned OpenRTB provider that combines a `ProviderPlan`, common driver, profile enum, and existing platform transport. +3. Add an internal/test constructor through which the orchestrator owns immutable plan-backed bidder providers and routes filtered inputs before launch. Production startup remains on the legacy constructor until Stage 11. +4. Preserve the existing split request/parse token and carry typed profile-local parse state without exposing another provider's state. +5. Prepare split mock mediator registration/lookup from bidder-provider storage. Keep its invocation and fallback unchanged, and restore `returned_seat` from the selected original provider bid rather than deriving it from mediator output. +6. Compute exact `logical_budget_ms` once for launch and `tmax`, then separately compute adapter-canonicalized `transport_timeout_ms` for backend naming/timers. Add Fastly tests proving timeout quantization never changes `tmax`. +7. Load the current signer once during common admission before routing/dispatch. A load failure returns before any `send_async` call. Do not cache signer keys at startup. +8. Ensure one request per eligible provider, no request for skipped providers, and no zero-impression request. +9. Keep provider failures isolated and preserve current local ranking/floor logic after normalization. +10. Add multi-instance tests for two providers with the same profile, endpoint, and timeout, proving independent backend correlation and outcome metadata. + +Stages 2 through 10 use internal construction scaffolding and tests without changing the public configuration/runtime path. They are development order, not independently mergeable public migrations. Stage 11 performs the one atomic cutover; the merged result has one config-first runtime path and no public legacy provider list. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` + +## Stage 7 — Extract and migrate the Prebid Server profile + +Files: + +- `crates/trusted-server-core/src/integrations/prebid.rs` +- `crates/trusted-server-core/src/auction/openrtb.rs` +- `crates/trusted-server-core/src/auction/profile.rs` +- `crates/trusted-server-core/src/auction/orchestrator.rs` + +Steps: + +1. Define typed `PrebidProfileConfig` for debug, test mode, debug query params, override fields/rules, and consent forwarding. Prepare server endpoint, timeout, bidders, and notification suppression for common ownership, but keep the live legacy config fields until Stage 11. +2. Compile the current override engine once into `PrebidProfilePlan`. +3. Move PBS-only request behavior behind the profile: + - `imp.ext.prebid.bidder` from routed params; + - stored-request fallback for trusted routes; + - zone and generic override merge behavior; + - test/debug request fields and page query behavior; + - PBS-specific consent and Google Additional Consent policy; + - disabled-signing host/scheme extension fields. +4. Keep raw HTTP values out of the profile. Common Prebid transport applies the snapshot matrix for Cookie, UA, Referer, Accept-Language, and attested XFF. +5. Move PBS-only response behavior behind the profile: + - Prebid Cache coordinates; + - existing bid-status/debug metadata; + - request-local parse facts; + - current valid-sibling behavior and delivery bidder fallback. +6. Apply common notification suppression after PBS normalization and before ranking/mediation. +7. Exercise the profile through the internal planned-provider constructor while the live singleton remains unchanged. +8. Run the Stage 1 golden matrix against the new profile and compare serialized requests plus normalized outcomes. +9. Mark singleton registration and static backend discriminator for atomic removal in Stage 11. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +``` + +## Stage 8 — Extract and migrate the APS profile + +Files: + +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/auction/openrtb.rs` +- `crates/trusted-server-core/src/auction/profile.rs` +- `crates/trusted-server-core/src/auction/types.rs` +- APS renderer registration call sites under adapter/core startup + +Steps: + +1. Define typed `ApsProfileConfig` for account ID, debug, script opt-in, and inventory identity overrides. Prepare endpoint and timeout for common provider ownership, but keep the live legacy config fields until Stage 11. +2. Express current APS request differences as its fixed field policy and owned account/SDK extensions. +3. Preserve current response validation, exact minimized renderer envelope, script gate, creative URL policy, diagnostics, and deterministic candidate reduction. +4. Capture a valid string `seatbid.seat` as `returned_seat`; use no returned seat for missing/non-string values. Keep `Bid.bidder = "aps"` so the existing renderer path remains active. +5. Preserve current APS removal/non-exposure of notification URLs before common configurable suppression. +6. Add a narrow validated-plan query for APS renderer activation and test it through the internal constructor; production registry wiring switches in Stage 11. +7. Make `all_eligible` the parity migration configuration and test optional `explicit` routing separately. +8. Exercise the profile through the internal planned-provider constructor while the live singleton remains unchanged. +9. Run the Stage 1 APS matrix against the profile, including exact enabled/disabled signing fixtures. +10. Mark singleton registration and static backend discriminator for atomic removal in Stage 11. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +cd crates/trusted-server-js/lib && npx vitest run +``` + +## Stage 9 — Prepare browser Prebid separation and shim migration + +Files: + +- `crates/trusted-server-core/src/integrations/prebid.rs` +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- `crates/trusted-server-core/src/creative_opportunities.rs` +- `crates/trusted-server-core/src/publisher.rs` + +Steps: + +1. Define the post-cutover browser-only `[integrations.prebid]` shape, retaining `timeout_ms` and `debug` with 1000 ms/false defaults. +2. Prepare removal of server endpoint, server bidder allowlist, server debug, overrides, consent-forwarding, and suppression ownership, but do not change the live serde shape before Stage 11. +3. Keep browser bundle, account injection, script patterns, client-side bidders, and excluded GAM suffixes under `[integrations.prebid]`. +4. Add an injection path that consumes validated server-side bidder codes from `AuctionPlan`, not a second Prebid allowlist. Provider timeout/profile debug must never affect `pbjs.setConfig`; production startup passes the plan in Stage 11. +5. Preserve the current requestBids shim's initial and refresh snapshots, folding only server-side entries into `trustedServer.bidderParams` while leaving configured client-side bidders in the browser. +6. Add JS tests for: + - browser timeout/debug independence with multiple PBS plans; + - browser integration disabled while PBS profile compilation remains valid; + - mixed client-side, PBS, APS, and standard-provider demand; + - initial and refresh parameter preservation; + - empty stored-request envelopes; + - alternate returned bidder codes and APS renderer alias. +7. Update creative-opportunity tests to prove no hard-coded provider ID reaches client-controlled input. + +Verification: + +```bash +(cd crates/trusted-server-js/lib && npx vitest run) +(cd crates/trusted-server-js/lib && node build-all.mjs) +cargo test-fastly +``` + +## Stage 10 — Finalize diagnostics, notifications, deadlines, and mediation parity + +Files: + +- `crates/trusted-server-core/src/auction/orchestrator.rs` +- `crates/trusted-server-core/src/auction/types.rs` +- `crates/trusted-server-core/src/auction/telemetry.rs` +- `crates/trusted-server-core/src/auction/formats.rs` +- `crates/trusted-server-core/src/integrations/adserver_mock.rs` +- all adapter platform test modules + +Steps: + +1. Add the fixed `routing` metadata object: + - auction-level saturating `unroutable_bidder_count` in `OrchestrationResult.metadata` plus bounded structured logging; + - provider-level `skipped_no_eligible_slots = true`; + - provider-level saturating `unused_bidder_params_count`. +2. Carry only booleans/counts. Do not include parameter values or bidder-ID lists. +3. Apply `suppress_all` and exact `returned_seat` suppression after profile normalization and before ranking/mediation. +4. Verify that missing/non-string seats do not match suppression, PBS still serializes `unknown`, and APS still serializes `aps`. +5. Restore `returned_seat` from the original provider bid after mock mediation. Make the existing telemetry seat field prefer `returned_seat` and fall back to delivery bidder code; add direct and mediated APS tests proving provider ID, upstream seat, and `aps` remain distinct. +6. Implement capability-dependent late completion behavior: + - a future hard-total-deadline adapter would discard/classify timeout; + - every current adapter accepts an already-completed late response because none claims an enforceable total-request deadline; + - neither path launches new provider or mediator network work at zero remaining budget; + - local ranking/delivery still finishes; + - synchronous and split dispatch/collect agree. +7. Preserve response times as actual elapsed times and document wall-clock overrun limitations in adapter docs/tests. +8. Run regression tests for no mediator, mock mediation, mediator timeout/fallback, floors, winner selection, creative sanitization, Prebid Cache, APS renderer delivery, and telemetry provider IDs/seats. +9. Confirm routing metadata uses existing maps and does not add OpenRTB response or telemetry schema fields. + +Verification: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +## Stage 11 — Perform the atomic schema and runtime cutover + +Stages 2 through 10 prepare and test the new architecture internally. This stage is one non-partial cutover: do not commit or merge a state in which public settings, deploy validation, runtime startup, browser injection, examples, or fixtures disagree. + +Files: + +- `crates/trusted-server-core/src/auction_config_types.rs` +- `crates/trusted-server-core/src/config.rs` +- `crates/trusted-server-core/src/settings.rs` +- `crates/trusted-server-core/src/auction/mod.rs` +- `crates/trusted-server-core/src/integrations/registry.rs` +- `crates/trusted-server-core/src/integrations/prebid.rs` +- `crates/trusted-server-core/src/integrations/aps.rs` +- all four adapter `app.rs` startup files +- `crates/trusted-server-cli/src/run.rs` and target-validation tests +- `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml` +- relevant adapter startup/route fixtures +- `trusted-server.example.toml` +- `docs/guide/configuration.md` +- `docs/guide/integrations/prebid.md` +- `docs/guide/integrations/aps.md` +- relevant README/getting-started pages +- configuration/env-overlay/config-push tests + +Steps: + +1. Replace the public legacy provider list with map-shaped `[auction.providers]` and `[auction.bidders]` settings. +2. Compile one `Arc` per process startup, run target validation once, and pass that same plan to both `AuctionOrchestrator` and `IntegrationRegistry`. Do not recompile or inspect unresolved provider config in either consumer. +3. Switch all four adapters to the generic plan-backed provider constructor and provider-ID backend discriminator. +4. Make `validate_settings_for_deploy` run the same target-independent compiler. Make target-aware `config push` run the EdgeZero pre-write callback with the selected adapter capabilities; keep `config validate` target-independent with an explicit deferred-check message. Add end-to-end tests proving Cloudflare/Spin multi-provider configs fail before any remote read, prompt, or write. +5. Remove PBS/APS singleton bidder-provider builders and their static backend discriminators. Retain the mock mediator in its separate static registration path. +6. Apply the prepared Prebid browser/server config split. Pass validated browser server-side bidder codes from the shared plan into integration injection. +7. Use `AuctionPlan::has_profile(ProfileId::Aps)` or an equivalent narrow query to register APS renderer support even when `[integrations.aps]` is disabled. Test renderer presence on every adapter. +8. Remove obsolete integration-owned server fields and update all serde, env-overlay, config-push, adapter, integration, and browser fixtures in the same cutover. +9. Replace examples/docs with `[auction.providers.*]`, `profile_config`, and `[auction.bidders.*]`; keep `[auction].mediator` separate. +10. Document browser/server Prebid ownership, APS `all_eligible`, provider ID and extension bounds, target validation, and current no-hard-total-deadline limitations. Use only fictional/example values. +11. Run the complete cutover gate before committing: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +(cd crates/trusted-server-js/lib && npx vitest run) +(cd crates/trusted-server-js/lib && node build-all.mjs) +(cd docs && npm run format) +``` + +No committed stage may expose both public schemas. + +## Stage 12 — Full verification and cleanup + +1. Delete all internal staging adapters, duplicate legacy server configuration fields, singleton provider builders, unused static provider constants, and obsolete tests. +2. Confirm no bidder-provider profile receives `AuctionContext.request` or unrestricted `RuntimeServices`. +3. Confirm only common transport handles endpoint, backend, headers, redirects, response bounds, and dispatch. +4. Confirm the test-only standard endpoint mock is under `#[cfg(test)]` or integration-test code and absent from production binaries/config schema. +5. Run the complete repository gates: + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +(cd crates/trusted-server-js/lib && npx vitest run) +(cd crates/trusted-server-js/lib && npm run format) +(cd crates/trusted-server-js/lib && node build-all.mjs) +(cd docs && npm run format) +``` + +6. Run `git diff --check` and verify no generated, staged, or `.pi-subagents` artifacts are included. + +## Test matrix summary + +| Area | Required coverage | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Config compiler | IDs, protocol/profile lookup, typed profile config, defaults, routes, endpoint canonicalization, static ownership/bounds, notifications, signing structure. | +| Target validation | shared backend-name prediction parity, encoded collisions, fan-out rejection, deadline capability claims, all four adapters. | +| Admission | envelope missing/null/empty/malformed/partial/unknown/collision/bounds, zone, raw header snapshot, attested XFF. | +| Routing | explicit, all-eligible, trusted routes, mixed providers, unknown bidders, no banners, no parameter leakage, deterministic order. | +| Standard profile | request/response, static extensions, unused params, 204, malformed bids, exact signing on/off, redirects, test-only no-auth endpoint. | +| PBS parity | all standard-field differences, consent matrix, headers/Cookies, stored requests, overrides, debug/test, Cache, diagnostics, seats, suppression, exact signing on/off. | +| APS parity | account/SDK fields, inventory identity, consent, renderer/script policy, response validation, candidate reduction, seats versus `aps`, exact signing on/off. | +| Orchestration | one request/provider, skip/no dispatch, partial failures, dynamic correlation, synchronous/split, logical/hard/late deadlines. | +| Decision/delivery | local ranking, floors, USD, mock mediation/fallback, sanitization, PBS Cache, APS renderer. | +| Browser JS | browser config ownership, mixed demand, folding, client-side preservation, refresh snapshots, aliases/renderers. | +| Diagnostics | fixed metadata carrier, saturating counts, no params/IDs, provider IDs in telemetry, no schema expansion. | +| Configuration/docs | TOML maps, env overlays, example-only values, CLI validation, adapter limitations. | + +## Primary file checklist + +### Core control plane + +- [ ] `auction_config_types.rs`: raw provider/bidder schema and newtypes. +- [ ] `auction/plan.rs`: compiler, immutable plan, target validation. +- [ ] `auction/profile.rs`: profile registration and typed compiled enum. +- [ ] `config.rs`: shared deploy validation. +- [ ] `auction/mod.rs`: registry wiring and mediator separation. + +### Core runtime + +- [ ] `auction/routing.rs`: envelope normalization and provider-local inputs. +- [ ] `auction/openrtb.rs`: common request/response driver and standard profile. +- [ ] `auction/provider.rs`: dynamic generic planned provider seam. +- [ ] `auction/orchestrator.rs`: plan execution, deadlines, diagnostics, unchanged decision flow. +- [ ] `auction/types.rs`: admitted input and returned-seat identity. +- [ ] `auction/formats.rs`: normalized admission and delivery alias serialization. +- [ ] `auction/endpoints.rs`: header snapshot and auction-local signer load. +- [ ] `auction/telemetry.rs`: provider-ID parity, returned-seat preference with delivery-code fallback, and existing metadata consumption only. +- [ ] `integrations/adserver_mock.rs`: restore original returned seat without generalizing mediation. + +### Profiles and browser integration + +- [ ] `integrations/prebid.rs`: typed profile, browser/server split, header/Cookie parity. +- [ ] `integrations/aps.rs`: typed profile, renderer activation, seat/alias parity. +- [ ] `creative_opportunities.rs`: config-neutral demand and trusted stored intent. +- [ ] `publisher.rs`: shared admission/plan use for page auctions. +- [ ] `integrations/registry.rs`: consume the shared compiled plan for renderer and browser capability queries. +- [ ] `trusted-server-js/lib/src/integrations/prebid/index.ts`: browser config and routing shim. + +### Platforms and tooling + +- [ ] Core platform types/traits: capability contract and shared backend-name prediction. +- [ ] Fastly backend/platform/app: provider discriminator, logical/quantized timeout separation, and no total-deadline claim. +- [ ] Axum platform/app: name prediction, fan-out, and no total-deadline claim. +- [ ] Cloudflare platform/app: deterministic name prediction, one-provider validation, and late completion behavior. +- [ ] Spin platform/app: deterministic name prediction, one-provider validation, and late completion behavior. +- [ ] EdgeZero callback/dependency pin plus CLI tests: target-aware pre-write push validation, target-independent validation, and map-shaped overlays. + +### Documentation + +- [ ] `trusted-server.example.toml`. +- [ ] Configuration guide. +- [ ] Prebid guide. +- [ ] APS guide. +- [ ] Adapter timeout/fan-out notes where maintained. + +## Risk register + +| Risk | Mitigation | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Implementing from the stale spec branch regresses merged APS | Require implementation worktree from `main` at/after `af67d8c2`; run APS goldens first. | +| Common field extraction broadens privacy exposure | Fixed typed policies plus exhaustive consent/header golden matrices; profiles can omit but never restore centrally removed data. | +| Dynamic profile state becomes an unsafe type-erasure layer | Use a closed typed enum and typed request-state variants for v1. | +| Multiple provider instances collide in an adapter | Provider ID discriminator, shared prediction parity tests, compile-time target collision rejection, runtime guard retained. | +| Browser Prebid behavior silently follows one server profile | Keep browser timeout/debug explicit; derive only server bidder identities from the central registry. | +| Empty or malformed envelope unexpectedly fans out stored requests | Exact fallback table and admission tests; malformed input never triggers fallback. | +| Header refactor truncates or broadens Cookie/Referer disclosure | Snapshot the same first accepted header values with no new truncation; transport-only access; parity fixtures for malformed bytes. | +| APS seat preservation breaks renderer activation | Keep `Bid.bidder = "aps"`; add independent `returned_seat`; test direct and page delivery. | +| Disabled signing changes PBS wire shape | Preserve host/scheme-only object and pin exact enabled/disabled JSON fixtures. | +| Non-abortable adapters claim a wall-clock guarantee they cannot meet | Capability-specific semantics, adapter docs, synchronous/split late-response tests. | +| Mock mediator is accidentally generalized or routed | Separate storage/construction; retain existing `[auction].mediator` path and regression fixtures. | +| Public diagnostics leak bidder data or expand telemetry schema | Fixed existing metadata maps with booleans/saturating counts only. | +| Temporary dual architecture survives the refactor | Final cleanup stage and searches for singleton builders, legacy list fields, and static PBS/APS backend IDs. | +| Plan grows into unrelated privacy/auth/network policy work | Keep consent behavior, auth omission, and endpoint-network deferrals exactly as specified. | + +## Explicitly deferred + +Do not add during implementation: + +- real generic endpoint onboarding or endpoint credentials; +- a generic mediator/profile system; +- new signing protocol fields or body binding; +- new consent minimization or Cookie behavior; +- video/native support; +- currency conversion; +- label/group/multi-route routing; +- request splitting; +- new adapter abort/deadline mechanisms; +- new telemetry fields; +- broader private-network, custom-port, or DNS-rebinding policy. diff --git a/docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md b/docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md index 413edcecf..ca13e13e0 100644 --- a/docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md +++ b/docs/superpowers/specs/2026-08-10-config-first-auction-provider-architecture-design.md @@ -16,9 +16,9 @@ The auction orchestrator will no longer contain statically configured Prebid Ser - Trusted Server integrations may register profiles through a compile-time Rust registry. - A central bidder registry routes each client-requested bidder to exactly one provider. - Provider configuration is compiled and validated at startup into an immutable auction plan. -- The runtime orchestrator operates only on compiled provider plans and normalized auction data. +- The runtime bidder-provider path operates only on compiled provider plans and normalized auction data. -This redesign changes provider registration, routing, request construction, and response normalization. It deliberately preserves existing auction economics, privacy enforcement, signing behavior, creative delivery, mediation, and telemetry unless a structural change is required to support the new provider architecture. +This redesign changes provider registration, routing, request construction, and response normalization. It deliberately preserves existing auction economics, privacy enforcement, signing behavior, creative delivery, the existing statically registered mock mediation path, and telemetry unless a structural change is required to support the new provider architecture. It does not introduce a generic mediator type or mediator profile. > **Core principle:** Configuration defines provider instances. Rust code registers > tested protocols and profiles. A compiler turns configuration into an immutable @@ -112,12 +112,12 @@ Internally, profile implementations may share smaller components. Those componen The redesign preserves the current behavior of: -- Trusted Server request signing, using the existing signing protocol. +- Trusted Server request signing, using the existing signing protocol, while intentionally expanding enabled signing coverage to every OpenRTB provider. - Consent extraction, privacy enforcement, and identity gating. - Highest decoded-price winner selection. - Current floor enforcement. - Current USD assumptions. -- Optional external mediation. +- The existing statically registered mock mediation path. - Creative sanitization and delivery. - APS rendering behavior. - Prebid Cache handling. @@ -138,7 +138,8 @@ This specification does not include: - A new auction pricing or ranking model. - Currency conversion or a new money representation. - Changes to floor behavior. -- Changes to external mediation behavior. +- A generic mediator type, mediator profile, or configuration-first mediator architecture. +- Changes to the existing mock mediation behavior. - A new request-signing protocol. - A new privacy or consent system. - A new telemetry schema. @@ -165,10 +166,12 @@ A unique operator-defined provider instance, such as: ```text pbs-primary aps-primary -rubicon-direct +fictional-direct ``` -Provider health, runtime correlation, configuration, and telemetry use the provider ID. +Provider IDs must match `^[a-z][a-z0-9-]{0,62}$`. This lowercase ASCII grammar is part of the configuration contract: it excludes control characters and adapter-specific punctuation aliases, keeps backend names bounded, and still requires target-encoded collision validation. + +Provider health, runtime correlation, backend discrimination, configuration, and telemetry use the provider ID. ### Bidder ID @@ -194,6 +197,10 @@ A registered Rust implementation that augments generic OpenRTB request construct The buyer identity returned in `seatbid.seat`. A seat is not a provider ID and must not be used for transport correlation. +### Delivery bidder code + +The bidder code serialized to the browser-facing auction response. It is distinct from provider ID and returned seat because current delivery contracts differ: Prebid Server uses a valid returned seat or the fallback `unknown`, while APS must continue to use `aps` so the existing browser renderer activates. + ### Provider plan An immutable, validated runtime representation compiled from one provider's configuration. @@ -230,7 +237,9 @@ Raw configuration is not repeatedly interpreted during auctions. ### Auction configuration -The provider blocks are the source of truth. A separate ordered provider-name list is not required. +The provider blocks are the source of truth for bidder providers. A separate ordered bidder-provider name list is not required. + +The existing `[auction].mediator` reference may continue to select the current statically registered mock mediator. That mediator is not configured under `[auction.providers.*]`, is not bidder-routed, and is not compiled through the protocol and profile registry. This specification does not generalize mediation. ```toml [auction] @@ -249,9 +258,9 @@ protocol = "openrtb-2.6" profile = "aps" endpoint = "https://aps.example/bid" timeout_ms = 700 -routing = "explicit" +routing = "all_eligible" -[auction.providers.aps-primary.profile] +[auction.providers.aps-primary.profile_config] account_id = "example-account" allow_script_creatives = false @@ -262,7 +271,7 @@ endpoint = "https://rubicon.example/bid" timeout_ms = 650 routing = "explicit" -[auction.providers.rubicon-direct.profile] +[auction.providers.rubicon-direct.profile_config] request_ext = { account = "example-account" } imp_ext = { placementGroup = "display" } ``` @@ -274,9 +283,27 @@ imp_ext = { placementGroup = "display" } | `protocol` | Yes | None | Registered protocol identifier. First version supports only `openrtb-2.6`. | | `profile` | No | `standard` | Registered OpenRTB profile identifier. | | `endpoint` | Yes | None | Fixed operator-configured HTTPS endpoint. | -| `timeout_ms` | No | Auction timeout | Maximum provider timeout, capped by the remaining auction deadline. | +| `timeout_ms` | No | Profile default | Maximum provider timeout, capped by the remaining auction deadline. | | `routing` | No | `explicit` | `explicit` or `all_eligible`. | +Each provider may also define a `profile_config` table. The selected profile parses that table as typed configuration; an omitted table is treated as empty configuration. + +Standard OpenRTB notification suppression is common provider configuration: + +```toml +[auction.providers.pbs-primary.notifications] +suppress_all = false +suppress_seats = ["example-seat"] +``` + +- `suppress_all` removes `nurl` and `burl` from every normalized bid returned by the provider. +- `suppress_seats` removes those URLs only when the exact returned `seatbid.seat` value matches an entry. +- Seat suppression is independent of the bidder registry because returned seats may be aliases or originate from stored requests. +- Suppression entries must be nonempty, unique strings without ASCII control characters. A provider may configure at most 128 entries, and each entry may contain at most 128 UTF-8 bytes. +- Suppression is applied after response parsing and before bids reach ranking, mediation, or delivery. + +When `timeout_ms` is omitted, the compiler resolves it from the selected profile: `prebid-server` uses 1000 ms, `aps` uses 800 ms, and `standard` uses the auction timeout. An explicit provider value overrides that default. Runtime still caps the resolved timeout by the remaining auction deadline. + Provider presence under `[auction.providers.*]` means the provider is configured for the enabled auction. The implementation may add a conventional enablement field only if required by the broader settings system; it must not reintroduce a separate provider inventory. ### Bidder registry @@ -315,25 +342,26 @@ Enabling or disabling a browser integration does not register, enable, or disabl The auction plan compiler must reject configuration when: - A provider ID is duplicated or invalid. +- Two provider IDs collide after target-adapter backend-name encoding. - A protocol is unknown. - A profile is unknown. - A profile configuration cannot be parsed or validated. - A bidder references an unknown provider. - A bidder has more than one provider route. - A profile cannot support banner inventory. -- A provider endpoint is invalid or violates existing outbound endpoint requirements. +- A provider endpoint is not an absolute HTTPS URL with a nonempty host, contains URL credentials or a fragment, or violates stricter selected-profile endpoint requirements. - A provider's static extension configuration is not an object. - Static extensions exceed bounded size or nesting limits. - Static extensions collide with reserved fields owned by the OpenRTB driver, signing, or profile. -- Auction request signing cannot be initialized while auctions are enabled. +- Request signing is enabled but its structural configuration is missing or invalid. - More than one active provider is configured for a platform adapter that cannot perform concurrent fan-out. This target-specific validation is conservative because one auction may request bidders routed to different providers, and any `all_eligible` provider may participate alongside them. -The same compiler and registry must be used by: +Compilation has two explicit stages: + +1. Target-independent compilation parses settings, resolves profiles and defaults, validates routes and field ownership, canonicalizes endpoints, and produces the immutable plan. +2. Target validation receives that plan plus an adapter capability and shared pure backend-name prediction description. It validates fan-out support, deadline claims, and encoded backend-name uniqueness without rebuilding provider configuration or duplicating runtime naming algorithms. -- Deploy-time configuration validation. -- Runtime startup. -- Provider-plan construction. -- Configuration schema or documentation generation where supported. +The same compiler, registry, and target validator must be used by deploy tooling and runtime startup. A target-aware deploy or push passes the selected adapter description and must reject target-specific failures before publication. A target-agnostic `config validate` command runs the complete first stage and clearly reports that target checks are deferred; adapter startup remains the final mandatory target check. Configuration schema or documentation generation may consume the same registry where supported. ## Profile Registry @@ -358,19 +386,22 @@ A profile factory: 1. Parses its typed configuration. 2. Validates its configuration. 3. Reports supported media and creative representations. -4. Compiles immutable runtime profile behavior. +4. Declares its fixed typed standard-field policy. +5. Compiles immutable runtime profile behavior. ### Runtime responsibility A profile may: +- Declare a fixed typed policy for standard OpenRTB fields. - Augment a generic OpenRTB request within fields reserved to that profile. - Interpret provider-specific response extensions. - Apply provider-specific bid validation. +- Perform deterministic provider-local candidate reduction when required to preserve registered profile behavior. - Produce the existing normalized creative or renderer representation. - Extract provider-specific metadata required to preserve current behavior. -A profile must not overwrite fields owned by the OpenRTB driver, central privacy enforcement, or signing. Each profile declares the request extensions and response fields it owns, and the compiler rejects ownership collisions. +A profile must not directly overwrite fields owned by the OpenRTB driver, central privacy enforcement, or signing. The common driver applies the profile's compiled standard-field policy while constructing those fields. Each profile also declares the request extensions and response fields it owns, and the compiler rejects ownership collisions. A profile may not: @@ -379,11 +410,35 @@ A profile may not: - Register platform backends. - Resolve secrets. - Route other providers' bidders. -- Rank bids. +- Compare bids across providers, apply auction floors, or choose final auction winners. - Invoke mediation. - Override central privacy enforcement. - Modify another provider plan. +### Typed standard-field policy + +Central privacy enforcement defines the maximum data permitted for an auction. A compiled profile field policy may omit data from that approved view, but it cannot restore, derive, or request data that central enforcement removed. + +The common driver remains the only component that constructs standard OpenRTB fields. Each registered profile declares a fixed Rust policy for differences such as: + +- `imp.tagid`. +- Primary `banner.w` and `banner.h` fields. +- `banner.topframe`. +- `site.ref` forwarding. +- Precise latitude and longitude. + +These policies are registered, reviewed, and tested Rust behavior. They are not operator-configurable arbitrary field overrides. The `prebid-server` and `aps` profiles preserve their current field behavior through their respective policies. + +The `standard` profile uses the shared PBS and APS baseline. It includes request and impression IDs, banner formats, site domain and sanitized page URL, consent-approved user ID and EIDs, user agent, IP address, coarse geo, DNT, language, consent fields, floors, secure-impression requirements, effective timeout, and current USD currency assumptions. By default it omits `site.ref`, precise latitude and longitude, `imp.tagid`, primary `banner.w` and `banner.h`, and `banner.topframe`. + +Consent fields have a fixed wire policy rather than a profile-defined arbitrary map: + +- `standard` emits an admitted TCF string as `user.consent`; applies the current jurisdiction and applicability rules to `regs.gdpr` and `regs.ext.gdpr`; mirrors admitted USP, GPP, and GPP SID values in their current top-level `regs` and compatibility `regs.ext` placements; and omits `user.ext.ConsentedProvidersSettings`. +- `prebid-server` preserves that current OpenRTB policy plus its existing consent-forwarding mode and Google Additional Consent mapping in `user.ext.ConsentedProvidersSettings`. +- `aps` preserves its current OpenRTB consent placements and deliberately omits Google Additional Consent. + +Golden field-matrix tests are normative for absent consent, GDPR applicability and jurisdiction combinations, TCF, USP, GPP and section IDs, Google Additional Consent, and cookie-sourced versus KV- or policy-sourced consent. A common-driver extraction may not broaden one profile to fields currently exposed only by another. + ### Runtime inputs A profile receives only: @@ -395,9 +450,19 @@ A profile receives only: - The effective provider timeout. - Request-local parse state where required. -A profile does not receive the raw downstream HTTP request or unrestricted runtime services. Browser headers needed by OpenRTB must be normalized into canonical auction data before profile execution. +A profile does not receive the raw downstream HTTP request or unrestricted runtime services. Browser values needed by OpenRTB are normalized into canonical auction data before profile execution. + +Request admission also retains a transport-owned Prebid header snapshot containing exactly the first values selected by the current HTTP header API for `Cookie`, `User-Agent`, `Referer`, and `Accept-Language`. It preserves accepted header bytes without introducing a second truncation rule; the existing inbound request/header limit remains authoritative. The snapshot is not exposed to profiles or other unrestricted runtime code. Common Prebid transport forwards the current `User-Agent`, `Referer`, and `Accept-Language` values and synthesizes `X-Forwarded-For` only from the platform-attested client IP, never from a client-supplied `X-Forwarded-For` value. The Prebid field policy may also use the raw accepted `Referer` for its existing `site.ref` behavior; the canonical `site.page` remains sanitized separately. APS and `standard` do not receive these raw browser headers. -To preserve current Prebid consent-forwarding modes, request admission also produces a bounded, privacy-approved representation containing only the existing allowlisted consent-cookie names and values. The profile selects `openrtb_only`, `cookies_only`, or `both`; common OpenRTB request finalization and transport then apply that mode without exposing the raw browser cookie header to the profile. +To preserve current Prebid consent-forwarding behavior, the compiled Prebid profile selects `openrtb_only`, `cookies_only`, or `both`, and common transport applies the existing behavior to the snapshotted `Cookie` value: + +- `both` and `cookies_only` forward the complete selected `Cookie` header value unchanged. +- `openrtb_only` removes the existing allowlisted consent-cookie names and forwards the remaining cookies. +- When the header cannot be parsed by the existing stripping path, current fallback behavior is preserved, including forwarding the original non-UTF-8 value. +- If stripping removes every cookie, the upstream `Cookie` header is omitted. +- Consent originating from KV or policy state remains in the OpenRTB body when no browser consent cookie can carry it, including in `cookies_only` mode. + +No other first-version profile receives a browser `Cookie` header. Reducing Prebid forwarding to consent cookies only, changing malformed-header handling, or otherwise redesigning these modes requires a separate consent-focused specification. ## Canonical Auction Model @@ -430,11 +495,33 @@ AuctionSlot { ``` - `bidder_params` is keyed by bidder ID and originates from client or server auction input. -- `trusted_provider_routes` is available only to trusted server-side opportunity construction. +- `trusted_provider_routes` is produced only by trusted server-side opportunity construction or admission normalization of a recognized integration envelope. - Client input cannot choose provider IDs directly. ## Routing Model +### Prebid browser-envelope normalization + +The reserved `trustedServer` browser-adapter entry is an admission envelope, not a bidder ID. Before central routing, request admission unpacks its bounded `bidderParams` object into the canonical slot bidder map: + +```text +trustedServer.bidderParams.rubicon → bidder_params.rubicon +trustedServer.bidderParams.pubmatic → bidder_params.pubmatic +``` + +Each nested key becomes the client-requested bidder ID and is resolved only through `[auction.bidders]`. Nested values become that bidder's parameters. The envelope cannot name provider IDs or endpoints. Its optional `zone` value is preserved as a bounded Prebid slot-matching fact for existing override rules; it does not participate in provider routing. + +A usable `bidderParams` value is a bounded JSON object containing at least one nonempty bidder key whose value is an object accepted by the existing bidder-parameter admission rules. The fallback cases are exact: + +- Missing, `null`, or an empty `bidderParams` object creates stored-request routes to every configured `prebid-server` plan. +- A non-object `bidderParams`, an invalid bidder key or value, or a bounds violation is malformed input and does not trigger stored-request fan-out. +- An object containing a structurally valid but unregistered bidder is usable; it produces `unroutable_bidder` during central routing and does not trigger stored-request fallback. +- A partially malformed object is rejected rather than partially routed. + +A programmatic request may contain both a direct bidder entry and the same bidder inside the envelope. To preserve the existing Prebid merge rule, a usable direct object wins; an unusable direct value cannot overwrite a usable envelope value. Admission applies this rule deterministically before central routing. It never relies on map iteration order. + +When fallback applies, request admission derives a server-controlled stored-request route to each configured `prebid-server` provider plan. The client still does not select those provider IDs. Each routed Prebid plan receives the slot without inline bidder parameters and applies the existing stored-request fallback. This preserves initial and refresh auction behavior that currently uses an empty synthetic `trustedServer` bid. + ### Client-originated demand For each bidder requested on a slot: @@ -477,7 +564,9 @@ pbs-primary Server-generated opportunities that intentionally rely on stored requests may name trusted provider routes without supplying bidder parameters. -This supports the existing Prebid stored-request path without allowing the browser to choose an endpoint. +For migration, existing creative-opportunity construction expresses empty Prebid stored-request intent without a provider ID; the trusted router expands it to every configured `prebid-server` plan, matching the browser-envelope rule. Explicit creative-opportunity bidder parameters continue through `[auction.bidders]`. Creative opportunities no longer hard-code an APS provider instance: an `aps` plan configured as `all_eligible` receives every compatible slot, while an explicitly routed APS plan participates only through a centrally routed bidder or a trusted server-generated route. + +This supports the existing Prebid stored-request and APS paths without allowing the browser to choose an endpoint. ### Routing modes @@ -494,7 +583,7 @@ This is the default. The provider receives every banner-compatible slot, regardless of bidder routes. -This mode must be explicitly configured and exists to preserve use cases similar to the current APS behavior. +This mode must be explicitly configured. It is the migration-equivalent routing mode for the current APS provider, which receives every banner-compatible slot. Operators may deliberately choose `explicit` for narrower APS participation. ### No eligible slots @@ -520,7 +609,7 @@ It contains only: ## OpenRTB 2.6 Driver -The generic driver owns standard banner OpenRTB behavior. +The generic driver owns standard banner OpenRTB behavior and applies the selected profile's compiled standard-field policy. ### Request responsibilities @@ -533,14 +622,14 @@ The generic driver owns standard banner OpenRTB behavior. - `tmax` using the effective timeout. - Current secure-impression requirements. - Current auction currency assumptions. -- Existing Trusted Server signing extension, using the existing signing protocol. +- Common Trusted Server signing finalization when enabled, plus the documented PBS host/scheme-only behavior when disabled. ### Response responsibilities - HTTP 204 and ordinary empty responses as no-bid where currently supported. - Standard OpenRTB response decoding. -- Request ID correlation. -- `seatbid.seat` preservation. +- Transport association between the dispatched provider request and its response. For parity, omission or mismatch of the OpenRTB response `id` alone does not reject a PBS or APS response in the first version; profiles may preserve stricter existing behavior where one already exists. +- `seatbid.seat` preservation independently from delivery bidder code. - Standard bid ID, impression ID, price, dimensions, domains, creative markup, and notification URLs. - Current banner compatibility checks. - Existing response-size bounds. @@ -575,9 +664,9 @@ Static extensions: ### Ordinary field overrides -The first version does not support arbitrary overrides of fields such as `site.domain`, `device.ip`, `user.id`, or `imp.tagid`. +The first version does not support operator-configured arbitrary overrides of fields such as `site.domain`, `device.ip`, `user.id`, or `imp.tagid`. -Typed configuration for additional standard fields should be added only when a concrete endpoint requires it. +A registered profile's fixed typed standard-field policy is not an arbitrary override. Typed operator configuration for additional standard fields should be added only when a concrete endpoint requires it. ## Prebid Server Profile @@ -587,7 +676,7 @@ The `prebid-server` profile preserves required Prebid Server behavior while dele - Construct `imp.ext.prebid.bidder` from routed bidder parameters. - Preserve current deterministic bidder-parameter merging and validation semantics where still applicable. -- Support stored-request fallback for trusted server-generated provider routes. +- Support stored-request fallback for trusted server-generated provider routes and admission-generated empty `trustedServer` envelopes. - Add Prebid-specific request extensions and test/debug fields. - Preserve Prebid Cache coordinate extraction. - Preserve Prebid response diagnostics required by current behavior. @@ -612,20 +701,20 @@ The central bidder registry is the sole server-side bidder allowlist and route s The first version must preserve these server-side controls and defaults: -| Current control | New owner | Default and validation | -| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `server_url` | Common provider `endpoint` | Required fixed HTTPS endpoint. | -| `timeout_ms` | Common provider `timeout_ms` | Inherits the auction timeout when omitted. | -| `bidders` | Central `[auction.bidders]` registry | Every bidder route is explicit and unique. | -| `debug` | `auction.providers..profile.debug` | `false`; preserves current request and response debug behavior. | -| `test_mode` | `auction.providers..profile.test_mode` | `false`; preserves the current OpenRTB test flag. | -| `debug_query_params` | `auction.providers..profile.debug_query_params` | Absent by default; preserves current page-URL behavior when configured. | -| `bid_param_zone_overrides` | `auction.providers..profile.bid_param_zone_overrides` | Empty by default; preserves current typed validation and merge behavior. | -| `bid_param_overrides` | `auction.providers..profile.bid_param_overrides` | Empty by default; preserves current typed validation and merge behavior. | -| `bid_param_override_rules` | `auction.providers..profile.bid_param_override_rules` | Empty by default; preserves current rule validation, ordering, and shallow-merge behavior. | -| `consent_forwarding` | `auction.providers..profile.consent_forwarding` | `both`; preserves the existing `openrtb_only`, `cookies_only`, and `both` behavior. | -| `suppress_nurl` | Common `auction.providers..notifications.suppress_all` | `false`. | -| `suppress_nurl_bidders` | Common `auction.providers..notifications.suppress_bidders` | Empty; every entry must name a bidder routed to this provider. | +| Current control | New owner | Default and validation | +| -------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `server_url` | Common provider `endpoint` | Required fixed HTTPS endpoint. | +| `timeout_ms` | Common provider `timeout_ms` | Defaults to 1000 ms for `prebid-server`; explicit values override it. | +| `bidders` | Central `[auction.bidders]` registry | Every bidder route is explicit and unique. | +| `debug` | `auction.providers..profile_config.debug` | `false`; preserves current request and response debug behavior. | +| `test_mode` | `auction.providers..profile_config.test_mode` | `false`; preserves the current OpenRTB test flag. | +| `debug_query_params` | `auction.providers..profile_config.debug_query_params` | Absent by default; preserves current page-URL behavior when configured. | +| `bid_param_zone_overrides` | `auction.providers..profile_config.bid_param_zone_overrides` | Empty by default; preserves current typed validation and merge behavior. | +| `bid_param_overrides` | `auction.providers..profile_config.bid_param_overrides` | Empty by default; preserves current typed validation and merge behavior. | +| `bid_param_override_rules` | `auction.providers..profile_config.bid_param_override_rules` | Empty by default; preserves current rule validation, ordering, and shallow-merge behavior. | +| `consent_forwarding` | `auction.providers..profile_config.consent_forwarding` | `both`; preserves the existing `openrtb_only`, `cookies_only`, and `both` behavior. | +| `suppress_nurl` | Common `auction.providers..notifications.suppress_all` | `false`; preserves global `nurl` and `burl` suppression. | +| `suppress_nurl_bidders` | Common `auction.providers..notifications.suppress_seats` | Empty; exact returned seat IDs, validated independently of bidder routes. | Stored-request fallback remains built-in Prebid profile behavior rather than another configuration switch. Existing browser-only fields, including bundle configuration, script patterns, client-side bidders, account injection, and excluded GAM ad-unit suffixes, remain under `[integrations.prebid]`. @@ -640,8 +729,9 @@ The Prebid browser integration continues to own: - Browser adapter behavior. - Client-side bidder configuration. - Script interception and rewriting. +- Browser `timeout_ms` and `debug`, with their current defaults of 1000 ms and `false`, for the injected global Prebid.js configuration. -It does not own the server-side Prebid provider endpoint or bidder route map. +Browser `timeout_ms` and `debug` are independent from every server-side provider's common timeout and `profile_config.debug`. No value is selected from multiple provider plans for browser injection. The browser integration does not own the server-side Prebid provider endpoint or bidder route map. ## APS Profile @@ -653,10 +743,12 @@ The `aps` profile preserves APS-specific OpenRTB and rendering behavior. - Preserve APS inventory identity behavior. - Interpret the APS response shape and extension fields. - Preserve APS-specific bid validation. +- Preserve the current highest-price-per-impression candidate reduction and bid-ID tie-breaker, including displaced-bid diagnostics. - Extract creative URL and tag type. - Produce the existing typed APS renderer descriptor. - Preserve script-creative opt-in behavior. - Preserve APS diagnostics required by current behavior. +- Preserve a valid returned `seatbid.seat` separately while continuing to mark accepted APS bids with delivery bidder code `aps` for the existing browser renderer. ### Responsibilities moved to common architecture @@ -673,33 +765,44 @@ The `aps` profile preserves APS-specific OpenRTB and rendering behavior. The first version must preserve these APS controls and defaults: -| Current control | New owner | Default and validation | -| ------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `endpoint` | Common provider `endpoint` | Required fixed HTTPS endpoint; legacy unsupported endpoint forms remain rejected. | -| `timeout_ms` | Common provider `timeout_ms` | Inherits the auction timeout when omitted. | -| `account_id` | `auction.providers..profile.account_id` | Required, nonempty, and subject to the current size and input validation. | -| `debug` | `auction.providers..profile.debug` | `false`; preserves current debug behavior. | -| `allow_script_creatives` | `auction.providers..profile.allow_script_creatives` | `false`; preserves the existing explicit script opt-in. | -| `inventory_domain` | `auction.providers..profile.inventory_domain` | Absent by default; preserves current domain validation. | -| `inventory_page_origin` | `auction.providers..profile.inventory_page_origin` | Absent by default; must be configured with `inventory_domain` and preserve current origin/domain validation. | +| Current control | New owner | Default and validation | +| ------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `endpoint` | Common provider `endpoint` | Required fixed HTTPS endpoint; legacy unsupported endpoint forms remain rejected. | +| `timeout_ms` | Common provider `timeout_ms` | Defaults to 800 ms for `aps`; explicit values override it. | +| `account_id` | `auction.providers..profile_config.account_id` | Required, nonempty, and subject to the current size and input validation. | +| `debug` | `auction.providers..profile_config.debug` | `false`; preserves current debug behavior. | +| `allow_script_creatives` | `auction.providers..profile_config.allow_script_creatives` | `false`; preserves the existing explicit script opt-in. | +| `inventory_domain` | `auction.providers..profile_config.inventory_domain` | Absent by default; preserves current domain validation. | +| `inventory_page_origin` | `auction.providers..profile_config.inventory_page_origin` | Absent by default; must be configured with `inventory_domain` and preserve current origin/domain validation. | -Parity tests must cover defaults, debug behavior, inventory override validation, iframe creatives, permitted script creatives, and rejected script creatives. +Parity tests must cover defaults, `all_eligible` routing as the current-behavior migration path, optional narrower `explicit` routing, debug behavior, inventory override validation, iframe creatives, permitted script creatives, and rejected script creatives. Using the APS profile must activate any server-side renderer support it requires independently of browser integration enablement. ## Request Signing -Trusted Server request signing is an auction-wide requirement. +Request signing remains controlled by the existing optional global configuration. When global signing is enabled, signing is an auction-wide requirement and every OpenRTB provider request contains the complete version 1.1 Trusted Server signing extension. + +When global signing is disabled, no provider request contains signature-bearing fields (`version`, `signature`, `kid`, or `ts`). To preserve current Prebid wire behavior, the `prebid-server` profile may still emit `ext.trusted_server` containing only its existing `request_host` and `request_scheme` fields. APS and `standard` emit no `ext.trusted_server` object while signing is disabled. Static extension configuration cannot claim this reserved object. + +Applying the complete extension to APS and configuration-defined standard providers is an intentional coverage expansion from the current PBS-only behavior. It is not request-body signing and is not described as wire-parity with the current APS request. The project will: -- Reuse the existing signing implementation and wire contract. +- Reuse the existing signing implementation and version 1.1 wire contract. - Avoid cryptographic or protocol redesign. -- Apply existing signing behavior to every OpenRTB provider request. - Keep signing configuration global rather than repeated under providers. -- Fail startup when auctions are enabled and required signing infrastructure cannot be initialized. +- Compile only the enabled signing policy into the immutable auction plan; loaded key material is never stored in the plan. +- Load the current signer once during auction admission, before provider routing and dispatch, when global signing is enabled. +- Fail the auction before any provider request is dispatched when the current signer cannot be loaded. +- Reuse that auction-local signer for every provider request in the fan-out. +- Preserve live key rotation by loading the current key for each admitted auction rather than only at process startup. + +Signing version 1.1 authenticates only its existing canonical payload: version, key ID, publisher host, publisher scheme, OpenRTB request ID, and timestamp. It does not authenticate the serialized OpenRTB body, provider ID, endpoint, bidder parameters, or profile extensions. Body or provider binding requires a future signing-protocol version and is outside this specification. + +Profiles augment their owned request fields before common request finalization. The common finalizer then inserts the signing extension and freezes the request ID and signing-owned fields. This ordering prevents profiles or static extensions from overwriting signing fields; it does not imply that version 1.1 authenticates the augmented body. -The exact existing signing payload and verification behavior remain unchanged by this specification. +Parity and compatibility tests must prove that Prebid Server preserves the existing signing contract and that APS accepts requests containing the extension. The standard-profile compatibility endpoint is a fictional local mock that exists only in automated tests. It implements the documented standard OpenRTB subset, requires no authentication or additional typed fields, and must not become a runtime endpoint, built-in provider, production-support claim, or new profile. ## Transport and Execution @@ -712,19 +815,38 @@ The existing platform transport abstractions remain responsible for: - Existing timeout behavior. - Existing platform-specific fan-out capability checks. +The compiler canonicalizes each provider endpoint once. The endpoint must be an absolute HTTPS URL with a nonempty host and no embedded username, password, or fragment. The same canonical endpoint supplies both the outbound request URI and the platform backend specification. Automatic redirects are not followed; a different destination requires a configuration change and recompilation. Existing TLS certificate and hostname verification remain enabled. Registered profiles may impose stricter endpoint validation, including the APS legacy-endpoint rejection. + +Every provider backend specification uses the validated provider ID as its discriminator. A profile ID is never sufficient for backend discrimination because multiple provider instances may use the same profile, endpoint, and timeout. Target-specific validation must reject provider IDs that collide after any adapter-specific backend-name encoding; lossy normalization may not silently merge them. + +Dispatch state associates the resulting backend identity with exactly one provider ID and compiled profile within an auction. A collision must fail before either mapping can overwrite the other. This first-version contract preserves the existing backend-name correlation mechanism without introducing a new cross-adapter per-request token system. + At most one outbound request is sent per provider per auction. Exactly one request is sent for each provider with eligible slots, containing every slot admitted by its routing mode. The first version does not split one provider's slots across multiple requests and does not send one request per slot. -The existing auction deadline remains authoritative: +The runtime always computes the logical provider budget as: ```text min(provider timeout, auction time remaining) ``` +That exact logical budget controls whether a provider may launch, the OpenRTB `tmax` value, and whether later upstream or mediator network work may launch. It is distinct from a transport timeout used for backend construction and from a hard transport deadline. An adapter may canonicalize or quantize its transport timeout for stable backend identity, but that derived value must not replace the exact logical budget or shorten `tmax`. + +A hard network deadline is enforced only when the target adapter exposes an abortable total-request deadline. On such an adapter, a completion after the enforced deadline is discarded and classified as a provider timeout. + +Fastly currently provides first-byte and between-byte backend timeout controls, not an absolute total-request deadline. Axum has broader task/client cancellation behavior, and Cloudflare and Spin may use eager or broader platform HTTP execution, but no current adapter claims an enforceable provider-wide total-request deadline for this capability. To preserve current behavior, an already-launched call may therefore complete after its logical budget and its completed response remains eligible for local ranking and delivery. No additional provider or mediator network work may launch once the auction has no remaining logical budget, but local decision and delivery still complete. This rule applies equally when split dispatch/collect observes a completed response after the logical deadline. Documentation and tests must state that such an auction can exceed the configured wall-clock budget. + +The plan compiler and target-specific validator use an adapter capability description that distinguishes: + +- Concurrent fan-out support. +- Enforceable total-request transport deadlines. + +Adapters without concurrent fan-out continue to reject configurations with more than one active bidder provider. An adapter without an enforceable outbound deadline may still run one provider, preserving current behavior, but its documentation and tests must identify the transport limitation. + Provider failures remain isolated from other provider outcomes. -Custom endpoint authentication is included only if required by the first concrete generic endpoint. This specification does not define speculative bearer-token, custom-header, or secret-store authentication schemas. +The automated standard-profile endpoint fixture is unauthenticated and test-only. This specification does not define bearer-token, custom-header, secret-store, or other endpoint-authentication schemas. ## Response Normalization @@ -733,7 +855,8 @@ Every provider response is normalized into the existing shared auction response The normalized result must preserve: - Provider ID. -- Returned seat. +- Valid returned seat, when present. +- Delivery bidder code. - Slot/impression ID. - Bid ID. - Decoded price and existing currency assumptions. @@ -742,13 +865,21 @@ The normalized result must preserve: - Existing notification URL behavior. - Provider-specific metadata required for current diagnostics. -Profile-specific response interpretation occurs before bids reach ranking or mediation. +Profile-specific response interpretation occurs before bids reach auction ranking or mediation. A registered profile may deterministically reduce its own provider response when required for parity, but it cannot compare bids across providers, apply auction floors, select final winners, or invoke mediation. + +Identity normalization is explicit: + +- A valid string `seatbid.seat` becomes `returned_seat`. +- A missing or non-string seat becomes no returned seat and cannot match `notifications.suppress_seats`. +- Prebid Server uses the valid returned seat as its delivery bidder code and otherwise preserves the current `unknown` fallback. +- APS always uses `aps` as its delivery bidder code, independently of its returned seat. +- Provider ID remains the only backend, health, and transport-correlation identity. One provider's malformed response does not fail another provider. Existing behavior for whether an invalid individual bid or full response is dropped should be preserved unless the common driver can enforce an equivalent stricter check without changing externally visible behavior. -## Decision, Mediation, and Delivery +## Decision, Mock Mediation, and Delivery -This project does not redesign the decision or delivery stages. +This project does not redesign the decision or delivery stages and does not introduce a generic mediator type. The existing `[auction].mediator` reference and statically registered mock mediator remain outside the compiled bidder-provider plan. Provider profiles cannot invoke mediation. After normalization, the existing system continues to: @@ -760,7 +891,7 @@ After normalization, the existing system continues to: - Sanitize and rewrite creatives according to existing settings. - Serialize standard creatives and APS renderer descriptors according to existing contracts. -Provider profiles do not rank bids or choose winners. +Provider profiles do not perform cross-provider ranking or choose final auction winners. Provider-local candidate reduction remains part of response normalization where explicitly registered for parity. ## Telemetry and Diagnostics @@ -777,7 +908,7 @@ The new architecture must preserve the ability to report: - Winner status. - Existing profile-specific diagnostics when enabled. -The redesign may centralize how diagnostics are carried, but it must not introduce a new telemetry product or schema as part of this work. +The redesign may centralize how diagnostics are carried, but it must not introduce a new telemetry product or schema as part of this work. The existing telemetry seat carrier uses `returned_seat` when present and otherwise falls back to the delivery bidder code to preserve missing-seat behavior. If mock mediation reconstructs a bid, it restores the original provider bid's returned seat rather than deriving it from the mediator or delivery alias. New routing outcomes should be distinguishable: @@ -785,7 +916,13 @@ New routing outcomes should be distinguishable: - `skipped_no_eligible_slots` - `unused_bidder_params` -These outcomes use existing bounded diagnostic or outcome fields. They do not add telemetry fields or change the meaning of existing outcomes. They must not include sensitive bidder parameters. +They use a fixed `routing` object in existing metadata maps rather than new response or telemetry fields: + +- Auction-level `OrchestrationResult.metadata["routing"]` carries `unroutable_bidder_count` for internal diagnostics and bounded structured logging. +- A skipped provider produces its ordinary provider result with `AuctionResponse.metadata["routing"].skipped_no_eligible_slots = true`, so the existing `ProviderSummary.metadata` carrier remains usable. +- A called provider that receives but does not consume routed parameter objects records `AuctionResponse.metadata["routing"].unused_bidder_params_count`. + +Only booleans and saturating counts are carried. Bidder parameter values and bidder-ID lists are never included. Existing telemetry may consume these existing metadata carriers, but this work adds no telemetry columns or new client response fields. ## Runtime Flow @@ -816,6 +953,7 @@ The system must preserve partial-auction behavior. | Unknown bidder in runtime request | Record `unroutable_bidder`; continue other demand. | | Provider has no eligible slots after applying its routing mode and banner filtering | Record `skipped_no_eligible_slots`; do not call provider. | | Profile configuration invalid | Reject configuration at deploy/startup. | +| Enabled auction signing cannot load the current signer | Fail the auction before any provider request is dispatched. | | Provider cannot build a valid request | Provider-local launch/build failure. | | Provider transport fails | Provider-local transport failure. | | Provider times out | Provider-local timeout. | @@ -831,7 +969,8 @@ No request with zero eligible impressions should be sent upstream. - Provider endpoints are fixed operator configuration, never client-derived. - Clients cannot select provider IDs or endpoint URLs. - Each bidder has one server-controlled provider route. -- Endpoint validation must preserve existing HTTPS and backend security requirements. +- Provider endpoints are canonical absolute HTTPS URLs with nonempty hosts and no URL credentials or fragments. +- The same canonical endpoint is used for the request URI and backend registration, redirects are not followed automatically, and existing TLS certificate and hostname verification remain enabled. - Static extensions cannot contain secrets or request templates. - Profiles cannot bypass central privacy enforcement. - Profiles cannot access raw browser requests or unrestricted runtime services. @@ -843,23 +982,33 @@ No request with zero eligible impressions should be sent upstream. ### Configuration and compilation -- [ ] Provider instances are configured under `[auction.providers.*]`. +- [ ] Provider instances are configured under `[auction.providers.*]`, with the selected profile's typed settings under `profile_config`. - [ ] The first version recognizes only `openrtb-2.6`. - [ ] `standard`, `prebid-server`, and `aps` profiles are registered through Rust profile factories. - [ ] Profile availability is independent of browser integration enablement. - [ ] Provider and profile configuration is compiled once at startup. -- [ ] Deploy validation and runtime startup use the same provider compiler and registry. -- [ ] Duplicate provider IDs, unknown profiles, invalid endpoints, and invalid bidder routes fail validation. +- [ ] Omitted provider timeouts resolve to 1000 ms for `prebid-server`, 800 ms for `aps`, and the auction timeout for `standard`; explicit values override those defaults. +- [ ] Deploy validation and runtime startup use the same two-stage provider compiler, profile registry, adapter capability descriptions, and shared backend-name prediction algorithms; target-agnostic validation reports deferred target checks. +- [ ] Provider IDs enforce the documented lowercase ASCII grammar and length; duplicate IDs, target-encoded backend-name collisions, unknown profiles, invalid endpoints, and invalid bidder routes fail validation. +- [ ] Endpoint tests require canonical absolute HTTPS URLs, reject missing hosts, credentials, and fragments, use the same URL for request and backend construction, and prove redirects are not followed automatically. +- [ ] Every backend specification uses the provider ID rather than the profile ID as its discriminator. +- [ ] Two provider IDs using the same profile, endpoint, and timeout dispatch and correlate independently. - [ ] Target-specific validation rejects more than one active provider on adapters without concurrent fan-out support. -- [ ] Auction startup fails when required existing signing infrastructure is unavailable. +- [ ] Adapter capabilities distinguish concurrent fan-out from enforceable total-request transport deadlines. +- [ ] Invalid enabled signing configuration fails deploy/startup validation. +- [ ] When signing is enabled, inability to load the current signer fails auction admission before any provider dispatch. ### Routing - [ ] Clients submit bidder identities and parameters without selecting providers. +- [ ] Request admission unfolds the reserved `trustedServer.bidderParams` envelope into canonical bidder IDs before routing. +- [ ] The reserved `trustedServer` envelope cannot name provider IDs or endpoints, and its `zone` value cannot influence routing. +- [ ] Missing, null, and empty recognized `trustedServer.bidderParams` create server-controlled stored-request routes to configured `prebid-server` plans; malformed, unknown-bidder, partial-validity, bounds, and direct/envelope collision cases follow the documented deterministic rules. +- [ ] Initial and refresh auction tests cover mixed PBS, APS, direct server-side, and client-side bidders, proving that only server-side entries are unfolded and routed. - [ ] `[auction.bidders]` routes each bidder to exactly one provider. - [ ] Unknown runtime bidders are recorded as `unroutable_bidder` without failing other demand. - [ ] `explicit` is the default provider routing mode. -- [ ] `all_eligible` is available only through explicit provider configuration. +- [ ] `all_eligible` is available only through explicit provider configuration and is documented as the current-behavior migration mode for APS. - [ ] Trusted server-generated slots may route directly to providers without inline bidder parameters. - [ ] Provider inputs contain only slots admitted by the provider's routing mode and only bidder parameters assigned to that provider. - [ ] `all_eligible` does not expose bidder parameters assigned to another provider. @@ -867,24 +1016,35 @@ No request with zero eligible impressions should be sent upstream. ### OpenRTB and profiles -- [ ] The common OpenRTB driver constructs current standard banner request fields. -- [ ] The common driver preserves existing consent, identity, floor, timeout, currency, and signing behavior. -- [ ] Tests prove that standard, Prebid Server, and APS requests apply the unchanged signing protocol after profile request augmentation. +- [ ] The common OpenRTB driver constructs current standard banner request fields by applying the selected profile's fixed typed field policy. +- [ ] Central privacy enforcement defines the maximum permitted data, and profile policies may only omit from that approved view. +- [ ] The `prebid-server` and `aps` field policies preserve their current request differences, including the complete consent-field matrix. +- [ ] The `standard` profile uses the documented shared PBS and APS baseline, consent placements, and omissions. +- [ ] Prebid transport preserves current `User-Agent`, raw `Referer`, `Accept-Language`, platform-attested `X-Forwarded-For`, and exact selected `Cookie` header behavior without exposing the raw request to profiles. +- [ ] The common driver preserves existing consent, identity, floor, timeout, currency, and signing wire semantics while intentionally expanding enabled signing coverage to every OpenRTB provider. +- [ ] Tests prove that standard, Prebid Server, and APS requests receive the version 1.1 signing extension after profile request augmentation when global signing is enabled. +- [ ] Tests prove that global signing disabled removes all signature-bearing fields, preserves the existing PBS-only host/scheme object, and omits `ext.trusted_server` from APS and `standard`. +- [ ] Signing tests document that version 1.1 does not bind the request body, provider ID, endpoint, bidder parameters, or profile extensions. +- [ ] APS and the automated, fictional standard-profile mock endpoint have signed-request compatibility fixtures; the latter exists only in tests and requires no runtime feature or authentication. - [ ] Standard OpenRTB endpoints can be configured without adding a provider implementation. - [ ] Static `request.ext` and `imp.ext` objects are bounded and validated. - [ ] Client bidder parameters are not assigned an invented generic wire location. - [ ] The Prebid profile preserves current bidder parameters, stored requests, cache handling, diagnostics, and notification behavior. -- [ ] Prebid parity tests cover `openrtb_only`, `cookies_only`, and `both` using only the canonical allowlisted consent-cookie representation. -- [ ] The APS profile preserves current account extensions, inventory identity, response validation, renderer, script policy, and diagnostics. +- [ ] Common notification suppression preserves `nurl` and `burl` by default, supports provider-wide suppression, and matches per-seat suppression against exact returned seat IDs independently of bidder routes. +- [ ] Prebid parity tests cover the exact existing `openrtb_only`, `cookies_only`, and `both` Cookie-header behavior, including KV/policy-sourced body-consent fallback. +- [ ] The APS profile preserves current account extensions, inventory identity, response validation, renderer, script policy, diagnostics, and deterministic highest-price-per-impression reduction with its bid-ID tie-breaker. - [ ] Prebid and APS no longer register singleton auction-provider instances. ### Runtime behavior - [ ] At most one outbound request is sent per provider per auction, and none is sent when the provider has no eligible slots. -- [ ] Existing concurrent fan-out and timeout behavior remains intact. +- [ ] The exact logical provider budget always controls launch eligibility and OpenRTB `tmax`; adapter-specific transport-timeout canonicalization does not replace or shorten it. +- [ ] Hard total-request deadlines discard late completions where supported and are not claimed for any current adapter without an abortable absolute deadline. +- [ ] On adapters without enforceable provider deadlines, completed late responses remain eligible, no new network work launches after logical budget exhaustion, and synchronous plus split execution tests document the possible wall-clock overrun. +- [ ] Existing adapter-specific concurrent fan-out and timeout behavior remains intact. - [ ] Provider failures remain isolated. -- [ ] Returned seats remain distinct from provider IDs. -- [ ] Existing winner selection, floors, mediation, creative delivery, and telemetry continue to behave as before. +- [ ] Provider ID, returned seat, and delivery bidder code remain distinct through direct and mediated outcomes, browser delivery, and the existing telemetry seat carrier; PBS and APS preserve their documented delivery aliases. +- [ ] Existing winner selection, floors, mock mediation, creative delivery, and telemetry continue to behave as before. - [ ] Banner behavior has parity with the existing Prebid and APS paths. - [ ] Non-banner formats are excluded before routing and are never emitted upstream. - [ ] A slot with no banner formats is skipped. @@ -902,10 +1062,14 @@ The following require separate requirements before implementation: - New money or currency-conversion models. - New signing protocol versions. - New privacy or data-sharing controls. +- Consent-cookie forwarding minimization or changes to existing malformed-header behavior. - New diagnostics and telemetry schemas. - General endpoint authentication configuration. - Arbitrary standard-field mappings. - Runtime or sandboxed profile extensions. +- Generic mediator types, mediator profiles, and configuration-first mediation. +- New abortable outbound-timeout implementations for adapters that do not currently provide them. +- Broader outbound-network policy for IP literals, private or reserved networks, custom ports, and DNS rebinding. ## Open Questions @@ -915,7 +1079,7 @@ Implementation planning must still identify: - The exact Rust profile-factory and compiled-profile interfaces. - The minimal changes required to separate the current Prebid and APS logic into common OpenRTB and profile-owned behavior. -- The first concrete generic OpenRTB endpoint and whether it requires authentication or additional typed fields. +- The internal organization of the unauthenticated, automated standard-profile mock endpoint and its fixtures. - The complete parity test fixture set for Prebid, APS, routing, signing, and split dispatch/collect execution. - The exact configuration representation needed by existing environment override and app-config tooling. From 90a9095182274791516c39308da777190c379924 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 13 Aug 2026 11:50:54 -0500 Subject: [PATCH 3/3] Add config-first auction providers Make auction behavior derive from one validated provider plan so startup, runtime routing, browser demand, and platform backend handling cannot drift across adapters.\n\nPreserve existing Prebid and APS behavior while allowing multiple typed OpenRTB providers and rejecting the retired list-shaped configuration. --- Cargo.lock | 1 - README.md | 3 +- TESTING.md | 48 +- crates/trusted-server-adapter-axum/src/app.rs | 10 +- .../src/platform.rs | 74 +- .../tests/routes.rs | 46 +- .../src/app.rs | 156 +- .../src/platform.rs | 73 +- .../trusted-server-adapter-fastly/Cargo.toml | 1 - .../trusted-server-adapter-fastly/src/app.rs | 63 +- .../src/backend.rs | 253 +- .../src/platform.rs | 162 +- .../src/tinybird.rs | 4 + crates/trusted-server-adapter-spin/src/app.rs | 104 +- .../src/platform.rs | 79 +- .../trusted-server-cli/src/prebid_bundle.rs | 5 - .../tests/config_env_overlay.rs | 72 +- .../trusted-server-core/src/auction/README.md | 93 +- .../src/auction/endpoints.rs | 35 +- .../src/auction/formats.rs | 56 +- crates/trusted-server-core/src/auction/mod.rs | 247 +- .../src/auction/openrtb.rs | 758 +++ .../src/auction/openrtb/test_executor.rs | 106 + .../src/auction/openrtb/tests.rs | 903 ++++ .../src/auction/orchestrator.rs | 4664 +++++++++++++++-- .../trusted-server-core/src/auction/plan.rs | 1173 +++++ .../src/auction/profile.rs | 325 ++ .../src/auction/provider.rs | 432 +- .../src/auction/routing.rs | 1177 +++++ .../src/auction/telemetry.rs | 113 +- .../src/auction/test_support.rs | 96 + .../trusted-server-core/src/auction/types.rs | 29 +- .../src/auction_config_types.rs | 79 +- crates/trusted-server-core/src/config.rs | 98 +- .../trusted-server-core/src/config_payload.rs | 34 - .../src/creative_opportunities.rs | 100 +- .../trusted-server-core/src/html_processor.rs | 28 +- .../src/integrations/adserver_mock.rs | 32 +- .../src/integrations/aps.rs | 809 ++- .../src/integrations/didomi.rs | 18 +- .../src/integrations/google_tag_manager.rs | 54 +- .../src/integrations/gpt_diagnostics.rs | 7 +- .../src/integrations/mod.rs | 8 - .../src/integrations/nextjs/mod.rs | 81 +- .../src/integrations/prebid.rs | 1513 +++++- .../src/integrations/registry.rs | 232 +- .../src/integrations/sourcepoint.rs | 9 +- .../src/platform/backend_naming.rs | 563 ++ .../trusted-server-core/src/platform/http.rs | 10 + .../trusted-server-core/src/platform/mod.rs | 5 + .../src/platform/test_support.rs | 155 +- .../src/platform/traits.rs | 14 +- crates/trusted-server-core/src/publisher.rs | 406 +- crates/trusted-server-core/src/settings.rs | 284 +- .../trusted-server-core/src/test_support.rs | 4 +- .../configs/trusted-server.integration.toml | 33 +- .../lib/src/integrations/prebid/index.ts | 71 +- .../test/integrations/prebid/index.test.ts | 235 +- .../test/prebid-artifact-integration.test.mjs | 22 +- docs/guide/api-reference.md | 140 +- docs/guide/architecture.md | 15 +- docs/guide/auction-orchestration.md | 273 +- docs/guide/configuration.md | 386 +- docs/guide/ec-setup-guide.md | 30 +- docs/guide/error-reference.md | 97 +- docs/guide/fastly.md | 5 +- docs/guide/first-party-proxy.md | 2 +- docs/guide/getting-started.md | 20 +- docs/guide/integration-guide.md | 21 +- docs/guide/integrations-overview.md | 34 +- docs/guide/integrations/aps.md | 126 +- docs/guide/integrations/prebid.md | 233 +- docs/guide/proxy-signing.md | 2 +- trusted-server.example.toml | 47 +- 74 files changed, 15373 insertions(+), 2323 deletions(-) create mode 100644 crates/trusted-server-core/src/auction/openrtb.rs create mode 100644 crates/trusted-server-core/src/auction/openrtb/test_executor.rs create mode 100644 crates/trusted-server-core/src/auction/openrtb/tests.rs create mode 100644 crates/trusted-server-core/src/auction/plan.rs create mode 100644 crates/trusted-server-core/src/auction/profile.rs create mode 100644 crates/trusted-server-core/src/auction/routing.rs create mode 100644 crates/trusted-server-core/src/platform/backend_naming.rs diff --git a/Cargo.lock b/Cargo.lock index cb8f40c68..455001fcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,7 +5283,6 @@ dependencies = [ "log-fastly", "serde", "serde_json", - "sha2 0.10.9", "trusted-server-core", "url", "urlencoding", diff --git a/README.md b/README.md index b87fe61ad..c606b340a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ ts --help # Create local config, then edit placeholders before validation ts config init -# Edit trusted-server.toml +# Edit trusted-server.toml. Server auctions use map-shaped +# [auction.providers.] and [auction.bidders.] tables. ts config validate # Audit a public page with Chrome/Chromium to bootstrap a draft config diff --git a/TESTING.md b/TESTING.md index e5ccba4cf..e2029b760 100644 --- a/TESTING.md +++ b/TESTING.md @@ -54,29 +54,35 @@ curl -X POST http://localhost:7676/auction \ - Logs showing: `"Using legacy Prebid flow"` - Direct Prebid Server call (backward compatible) -##Configuration +## Configuration Edit `trusted-server.toml` to customize the auction: ```toml -# Enable/disable orchestrator [auction] enabled = true -providers = ["prebid", "aps"] -mediator = "adserver_mock" # If set: mediation, if omitted: highest bid wins timeout_ms = 2000 +mediator = "adserver_mock" -# APS OpenRTB provider. The built-in production endpoint is used when -# endpoint is omitted; use only an account authorized for test traffic. -[integrations.aps] -enabled = true -account_id = "example-account" -timeout_ms = 800 -debug = false +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" +profile_config = { account_id = "example-aps-account", debug = false } + +[auction.bidders.example-server] +provider = "pbs-main" [integrations.adserver_mock] enabled = true -endpoint = "http://localhost:6767/adserver/mediate" +endpoint = "https://mediator.example.com/mediate" timeout_ms = 500 ``` @@ -87,8 +93,7 @@ timeout_ms = 500 ```toml [auction] enabled = true -providers = ["prebid", "aps"] -mediator = "adserver_mock" # Mediator configured = parallel mediation strategy +mediator = "adserver_mock" # Providers come from [auction.providers.*] maps ``` **Expected Flow:** @@ -102,8 +107,7 @@ mediator = "adserver_mock" # Mediator configured = parallel mediation strategy ```toml [auction] enabled = true -providers = ["prebid", "aps"] -# No mediator = parallel only strategy +# Configured [auction.providers.*] run without a mediator ``` **Expected Flow:** @@ -111,16 +115,16 @@ providers = ["prebid", "aps"] 2. Highest bid wins automatically 3. No mediation -### Scenario 3: Legacy Mode (Backward Compatible) +### Scenario 3: Auction Disabled + **Config:** + ```toml [auction] enabled = false ``` -**Expected Flow:** -- Original Prebid-only behavior -- No orchestration overhead +**Expected Flow:** no auction provider dispatch. ## Debugging @@ -149,10 +153,10 @@ INFO: Registering auction provider: adserver_mock ### Common Issues **Issue:** `"Provider 'aps' not registered"` -**Fix:** Make sure `[integrations.aps]` is configured in `trusted-server.toml` +**Fix:** Make sure an `[auction.providers.]` entry selects `profile = "aps"` **Issue:** `"No providers configured"` -**Fix:** Make sure `providers = ["prebid", "aps"]` is set in `[auction]` +**Fix:** Make sure map-shaped `[auction.providers.]` entries are configured **Issue:** Tests fail with WASM errors **Explanation:** Async tests don't work in WASM test environment. Integration tests via HTTP work fine! diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 613ce3235..0bb0af43c 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -10,7 +10,9 @@ use edgezero_core::http::{ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::auction::{ + AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, +}; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -69,8 +71,10 @@ fn build_state() -> Result, Report> { fn build_state_with_settings( settings: Settings, ) -> Result, Report> { - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; Ok(Arc::new(AppState { settings: Arc::new(settings), diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2..7dcdd53d8 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -9,9 +9,10 @@ use async_trait::async_trait; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -154,24 +155,15 @@ impl PlatformSecretStore for AxumPlatformSecretStore { pub struct AxumPlatformBackend; impl PlatformBackend for AxumPlatformBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Axum + } + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - let port = spec - .port - .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); - // Keep two providers that share an origin on distinct names so auction - // response correlation cannot cross providers. - let discriminator = spec - .discriminator - .as_deref() - .map(|d| format!("_p_{}", normalize_env_segment(d))) - .unwrap_or_default(); - Ok(format!( - "{}_{}_{}{}", - normalize_env_segment(&spec.scheme), - normalize_env_segment(&spec.host), - port, - discriminator, - )) + self.naming_policy() + .predict(spec) + .map(|prediction| prediction.name) + .change_context(PlatformError::Backend) } fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { @@ -601,6 +593,21 @@ mod tests { use std::time::Duration; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + #[test] + fn auction_http_capabilities_are_explicit() { + let client = AxumPlatformHttpClient::new(); + let capabilities = trusted_server_core::platform::AuctionTargetId::Axum + .descriptor() + .capabilities(); + assert!(client.supports_concurrent_fanout()); + assert!(capabilities.supports_concurrent_provider_fanout()); + assert!(!client.has_enforceable_total_request_deadline()); + assert!( + !capabilities.has_enforceable_total_request_deadline(), + "reqwest's transport timeout is not an adapter-enforced auction deadline" + ); + } + #[test] fn config_store_reads_from_env_var() { temp_env::with_var( @@ -693,6 +700,33 @@ mod tests { assert!(with_ip.is_none(), "should return None for any IP"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn http_client_surfaces_redirect_without_following() { + let url = serve_raw_response( + b"HTTP/1.1 302 Found\r\nLocation: https://redirect.example/next\r\nContent-Length: 0\r\n\r\n", + ) + .await; + let request = edgezero_core::http::request_builder() + .uri(url) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let response = AxumPlatformHttpClient::new() + .send(PlatformHttpRequest::new(request, "test_backend")) + .await + .expect("should surface redirect") + .response; + + assert_eq!(response.status().as_u16(), 302); + assert_eq!( + response + .headers() + .get(edgezero_core::http::header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("https://redirect.example/next") + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn http_client_strips_hop_by_hop_response_headers() { let url = serve_raw_response( diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 4b15b4c6a..a5155b3a6 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -18,8 +18,8 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The settings baked into the binary contain placeholder secrets that /// `get_settings()` rejects by design, which would turn every route into a /// startup error page (and its route table into the fallback-only set). -fn test_router() -> edgezero_core::router::RouterService { - let settings = trusted_server_core::settings::Settings::from_toml( +fn test_settings() -> trusted_server_core::settings::Settings { + trusted_server_core::settings::Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" @@ -36,9 +36,11 @@ fn test_router() -> edgezero_core::router::RouterService { passphrase = "test-secret-key-32-bytes-minimum" "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> edgezero_core::router::RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } @@ -62,6 +64,42 @@ fn assert_route_registered(method: &str, path: &str) { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_profile_serves_renderer_through_adapter_fallback() { + let mut settings = test_settings(); + settings.auction.providers.insert( + "aps-main".parse().expect("should parse APS provider ID"), + trusted_server_core::auction::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: trusted_server_core::auction::RoutingMode::AllEligible, + notifications: trusted_server_core::auction::NotificationConfig::default(), + profile_config: "{\"account_id\":\"example-account\"}" + .parse() + .expect("should parse APS profile config"), + }, + ); + let router = TrustedServerApp::routes_with_settings(settings) + .expect("should build router with APS profile"); + let mut service = EdgeZeroAxumService::new(router); + let request = Request::builder() + .method("GET") + .uri("/integrations/aps/renderer") + .body(AxumBody::empty()) + .expect("should build APS renderer request"); + + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should serve APS renderer"); + assert_eq!(response.status().as_u16(), 200); +} + /// Verify that every expected explicit route is registered in the route table. /// /// Uses [`RouterService::routes()`] for introspection rather than checking diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 9f2e40796..fcbb31e22 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -9,7 +9,9 @@ use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, he use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::auction::{ + AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, +}; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; @@ -107,8 +109,10 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -572,3 +576,149 @@ fn build_router(state: &Arc) -> RouterService { router.build() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn aps_profile_settings() -> Settings { + let mut settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-password" + + [publisher] + domain = "publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse startup test settings"); + settings.auction.providers.insert( + "aps-main".parse().expect("should parse APS provider ID"), + trusted_server_core::auction::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: trusted_server_core::auction::RoutingMode::AllEligible, + notifications: trusted_server_core::auction::NotificationConfig::default(), + profile_config: serde_json::json!({"account_id":"example-account"}), + }, + ); + settings + } + + #[test] + fn startup_registers_aps_renderer_route() { + let state = build_state_with_settings(aps_profile_settings()) + .expect("Cloudflare startup should register APS renderer"); + assert!( + state.registry.has_route( + &edgezero_core::http::Method::GET, + "/integrations/aps/renderer" + ), + "Cloudflare startup registry should expose the APS renderer" + ); + } + + #[test] + fn disabled_startup_accepts_dormant_multi_provider_auction_plan() { + let mut settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-password" + + [publisher] + domain = "publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse startup test settings"); + settings.auction.enabled = false; + settings.auction.providers = + std::iter::IntoIterator::into_iter(["provider-a", "provider-b"]) + .map(|id| { + ( + id.parse().expect("should parse provider ID"), + trusted_server_core::auction::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "standard".to_string(), + endpoint: format!("https://{id}.example/openrtb"), + timeout_ms: None, + routing: trusted_server_core::auction::RoutingMode::AllEligible, + notifications: + trusted_server_core::auction::NotificationConfig::default(), + profile_config: serde_json::json!({}), + }, + ) + }) + .collect(); + + build_state_with_settings(settings) + .expect("disabled Cloudflare auction should accept dormant fanout"); + } + + #[test] + fn startup_rejects_multi_provider_auction_plan() { + let mut settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-password" + + [publisher] + domain = "publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse startup test settings"); + settings.auction.enabled = true; + settings.auction.providers = + std::iter::IntoIterator::into_iter(["provider-a", "provider-b"]) + .map(|id| { + ( + id.parse().expect("should parse provider ID"), + trusted_server_core::auction::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "standard".to_string(), + endpoint: format!("https://{id}.example/openrtb"), + timeout_ms: None, + routing: trusted_server_core::auction::RoutingMode::AllEligible, + notifications: + trusted_server_core::auction::NotificationConfig::default(), + profile_config: serde_json::json!({}), + }, + ) + }) + .collect(); + + let error = match build_state_with_settings(settings) { + Ok(_) => panic!("Cloudflare startup should reject multi-provider fanout"), + Err(error) => error, + }; + assert!( + format!("{error:?}").contains("concurrent provider fanout"), + "should identify unsupported fanout: {error:?}" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed1..9853eefec 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -5,18 +5,16 @@ use std::time::Duration; use bytes::Bytes; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; -use error_stack::Report; +use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, - PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, - RuntimeServices, StoreId, StoreName, UnavailableKvStore, + BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, + PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, }; #[cfg(not(target_arch = "wasm32"))] use trusted_server_core::platform::UnavailableHttpClient; -#[cfg(target_arch = "wasm32")] -use error_stack::ResultExt as _; #[cfg(target_arch = "wasm32")] use trusted_server_core::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, @@ -61,27 +59,15 @@ impl PlatformSecretStore for NoopSecretStore { struct NoopBackend; impl PlatformBackend for NoopBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Cloudflare + } + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - let port = spec - .port - .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); - let timeout_ms = spec.first_byte_timeout.as_millis(); - let cert_suffix = if spec.certificate_check { - "" - } else { - "_nocert" - }; - // Keep two providers that share an origin on distinct names so auction - // response correlation cannot cross providers. - let discriminator = spec - .discriminator - .as_deref() - .map(|d| format!("_p_{d}")) - .unwrap_or_default(); - Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", - spec.scheme, spec.host, port - )) + self.naming_policy() + .predict(spec) + .map(|prediction| prediction.name) + .change_context(PlatformError::Backend) } fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { @@ -284,13 +270,22 @@ fn outbound_cache_mode(bypass_cache: bool) -> OutboundCacheMode { } } +#[cfg(target_arch = "wasm32")] +fn outbound_request_init(method: worker::Method, headers: worker::Headers) -> worker::RequestInit { + let mut init = worker::RequestInit::new(); + init.with_method(method) + .with_headers(headers) + .with_redirect(worker::RequestRedirect::Manual); + init +} + #[cfg(target_arch = "wasm32")] impl CloudflareHttpClient { async fn execute( &self, request: PlatformHttpRequest, ) -> Result> { - use worker::{CacheMode, Fetch, Headers, Method, Request, RequestInit, RequestRedirect}; + use worker::{CacheMode, Fetch, Headers, Method, Request}; // The Cloudflare fetch path cannot honor Fastly-style Image Optimizer // metadata, and it always buffers the response body (see below). The @@ -340,7 +335,6 @@ impl CloudflareHttpClient { } }; - let mut init = RequestInit::new(); // Force manual redirect handling: the Workers runtime otherwise defaults // to `RequestRedirect::Follow` and transparently chases 3xx responses to // any host inside `Fetch::send()`. Core's `proxy_with_redirects` does its @@ -348,9 +342,7 @@ impl CloudflareHttpClient { // `allowed_domains`; auto-following here would bypass that allowlist // (SSRF). `Manual` surfaces the 3xx + Location back to core unfollowed, // matching the Axum adapter's `redirect::Policy::none()`. - init.with_method(method) - .with_headers(headers) - .with_redirect(RequestRedirect::Manual); + let mut init = outbound_request_init(method, headers); // Setting the `cache` field requires the `cache_option_enabled` // compatibility flag, which is only on by default from compatibility // date 2024-11-11. `wrangler.toml`/`wrangler.ci.toml` pin an earlier @@ -762,6 +754,25 @@ fn reject_multi_provider_fanout(len: usize) -> Result<(), Report> mod tests { use super::*; use edgezero_core::context::RequestContext; + + #[cfg(target_arch = "wasm32")] + #[test] + fn outbound_request_creation_sets_manual_redirect_mode() { + let init = outbound_request_init(worker::Method::Get, worker::Headers::new()); + assert!(matches!(init.redirect, worker::RequestRedirect::Manual)); + } + + #[test] + fn auction_http_capabilities_are_explicit() { + let capabilities = trusted_server_core::platform::AuctionTargetId::Cloudflare + .descriptor() + .capabilities(); + assert!(!capabilities.supports_concurrent_provider_fanout()); + assert!( + !capabilities.has_enforceable_total_request_deadline(), + "Workers fetch does not expose an enforceable hard total request deadline" + ); + } use edgezero_core::http::{HeaderValue, request_builder}; use edgezero_core::params::PathParams; diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index b6bc0f1a1..2ad65462a 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -25,7 +25,6 @@ log = { workspace = true } log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -sha2 = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 5258d3455..b5029a18e 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -95,7 +95,9 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::auction::{ + AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, +}; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::batch_sync::handle_batch_sync; @@ -173,8 +175,10 @@ pub(crate) fn build_state_from_settings( ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; @@ -1292,7 +1296,6 @@ mod tests { [integrations.prebid] enabled = true - server_url = "https://test-prebid.com/openrtb2/auction" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [integrations.datadome] @@ -1300,7 +1303,10 @@ mod tests { [auction] enabled = true - providers = ["prebid"] + [auction.providers.prebid] + protocol = "openrtb-2.6" + profile = "prebid-server" + endpoint = "https://test-prebid.com/openrtb2/auction" timeout_ms = 2000 "#, ) @@ -1358,12 +1364,14 @@ mod tests { [integrations.prebid] enabled = true - server_url = "https://test-prebid.com/openrtb2/auction" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [auction] enabled = true - providers = ["prebid"] + [auction.providers.prebid] + protocol = "openrtb-2.6" + profile = "prebid-server" + endpoint = "https://test-prebid.com/openrtb2/auction" timeout_ms = 2000 "#, ) @@ -1382,8 +1390,13 @@ mod tests { filters: Vec>, ) -> RouterService { let settings = test_settings(); - let orchestrator = trusted_server_core::auction::build_orchestrator(&settings) - .expect("should build orchestrator"); + let plan = Arc::new( + trusted_server_core::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ); + let orchestrator = + trusted_server_core::auction::build_orchestrator_with_plan(plan, &settings) + .expect("should build orchestrator"); let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; @@ -1469,6 +1482,34 @@ mod tests { } } + #[test] + fn startup_registers_aps_renderer_route() { + let mut settings = test_settings(); + settings.auction.providers.clear(); + settings.auction.providers.insert( + "aps-main".parse().expect("should parse APS provider ID"), + trusted_server_core::auction::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: trusted_server_core::auction::RoutingMode::AllEligible, + notifications: trusted_server_core::auction::NotificationConfig::default(), + profile_config: serde_json::json!({"account_id":"example-account"}), + }, + ); + + let state = build_state_from_settings(settings) + .expect("Fastly startup should register APS renderer"); + assert!( + state.registry.has_route( + &edgezero_core::http::Method::GET, + "/integrations/aps/renderer" + ), + "Fastly startup registry should expose the APS renderer" + ); + } + #[test] fn startup_error_router_handles_head_and_options() { let report = Report::new(TrustedServerError::BadRequest { @@ -2207,6 +2248,10 @@ mod tests { struct FixedBackend; impl PlatformBackend for FixedBackend { + fn naming_policy(&self) -> trusted_server_core::platform::BackendNamingPolicy { + trusted_server_core::platform::BackendNamingPolicy::Fastly + } + fn predict_name( &self, spec: &PlatformBackendSpec, diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index f2ff5d9e5..c7a580234 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -1,13 +1,16 @@ -use core::fmt::Write as _; use std::time::Duration; use error_stack::{Report, ResultExt as _}; use fastly::backend::Backend; -use sha2::{Digest as _, Sha256}; use url::Url; use trusted_server_core::error::TrustedServerError; -use trusted_server_core::host_header::validate_host_header_override_value; +use trusted_server_core::platform::{BackendNamingPolicy, PlatformBackendSpec, PredictedBackend}; + +#[cfg(test)] +const MAX_BACKEND_NAME_LEN: usize = 255; +#[cfg(test)] +const SPEC_DIGEST_HEX_LEN: usize = 32; /// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP). #[inline] @@ -36,47 +39,6 @@ fn compute_host_header(scheme: &str, host: &str, port: u16) -> String { } } -fn sanitize_backend_name_component(value: &str) -> String { - value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { - ch - } else { - '_' - } - }) - .collect() -} - -/// Fastly's documented maximum length for a dynamic backend name. -const MAX_BACKEND_NAME_LEN: usize = 255; -/// Maximum length of the human-readable prefix folded into a backend name. -/// -/// Bounds the name so that `backend__` can never exceed -/// [`MAX_BACKEND_NAME_LEN`]: 8 (`backend_`) + 200 + 1 (`_`) + -/// [`SPEC_DIGEST_HEX_LEN`] = 241 ≤ 255. -const MAX_READABLE_PREFIX_LEN: usize = 200; -/// Width of the hex digest suffix — the first 128 bits of a SHA-256 over the -/// full backend spec, which is collision-resistant at the handful-of-hundreds -/// scale of a service's dynamic backends. -const SPEC_DIGEST_HEX_LEN: usize = 32; - -/// Hex-encode the first 128 bits of a SHA-256 digest of `canonical`. -/// -/// Used to make a backend name a collision-resistant function of the complete -/// backend spec (see [`BackendConfig::canonical_spec_string`]). -fn spec_digest_hex(canonical: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(canonical.as_bytes()); - let digest = hasher.finalize(); - let mut hex = String::with_capacity(SPEC_DIGEST_HEX_LEN); - for byte in digest.iter().take(SPEC_DIGEST_HEX_LEN / 2) { - write!(hex, "{byte:02x}").expect("should write hex digit to string"); - } - hex -} - /// Default first-byte timeout for backends (15 seconds). pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); /// Default timeout between response body bytes for backends (10 seconds). @@ -173,163 +135,47 @@ impl<'a> BackendConfig<'a> { self } - /// Build an unambiguous, length-prefixed encoding of the complete backend - /// spec for digesting. - /// - /// Every field is prefixed with its byte length so that no two distinct - /// specs can encode to the same string (a lossy substitution like - /// `sanitize_backend_name_component` cannot guarantee this). `Option` fields - /// are presence-tagged so a `None` never aliases a `Some("")`. The result is - /// fed to [`spec_digest_hex`]; it is never parsed, only hashed. - fn canonical_spec_string(&self, target_port: u16) -> String { - fn push_field(buf: &mut String, field: &str) { - buf.push_str(&field.len().to_string()); - buf.push(':'); - buf.push_str(field); + fn platform_spec(&self) -> PlatformBackendSpec { + PlatformBackendSpec { + scheme: self.scheme.to_owned(), + host: self.host.to_owned(), + port: self.port, + host_header_override: self.host_header_override.map(str::to_owned), + certificate_check: self.certificate_check, + first_byte_timeout: self.first_byte_timeout, + between_bytes_timeout: self.between_bytes_timeout, + discriminator: self.discriminator.map(str::to_owned), } - - let mut buf = String::new(); - push_field(&mut buf, self.scheme); - push_field(&mut buf, self.host); - push_field(&mut buf, &target_port.to_string()); - push_field(&mut buf, if self.certificate_check { "1" } else { "0" }); - match self.host_header_override { - Some(value) => { - buf.push('s'); - push_field(&mut buf, value); - } - None => buf.push('n'), - } - match self.discriminator { - Some(value) => { - buf.push('s'); - push_field(&mut buf, value); - } - None => buf.push('n'), - } - push_field(&mut buf, &self.first_byte_timeout.as_millis().to_string()); - push_field( - &mut buf, - &self.between_bytes_timeout.as_millis().to_string(), - ); - buf } /// Compute the deterministic backend name and resolved port without /// registering anything. - /// - /// The name is `backend__`, where `` is a - /// collision-resistant SHA-256 over an unambiguous encoding of the - /// *complete* backend spec — scheme, host, port, certificate setting, Host - /// override, provider discriminator, and the first-byte/between-bytes - /// timeouts (see [`canonical_spec_string`](Self::canonical_spec_string)). - /// Because distinct specs yield distinct digests, name equality implies spec - /// equality: that is what makes reusing a `NameInUse` backend provably safe, - /// and it prevents "first-registration-wins" poisoning where a later request - /// with a tighter timeout would inherit an earlier registration's value. The - /// `` half is a lossy, bounded slug carried only for logs — any - /// collision there is harmless because uniqueness comes from the digest. The - /// whole name is bounded to [`MAX_BACKEND_NAME_LEN`] so a long host or - /// discriminator can never produce a name Fastly rejects at registration. - fn compute_name(&self) -> Result<(String, u16), Report> { - if self.host.is_empty() { - return Err(Report::new(TrustedServerError::Proxy { - message: "missing host".to_owned(), - })); - } - if self.host.chars().any(char::is_control) { - return Err(Report::new(TrustedServerError::Proxy { - message: "host contains control characters".to_owned(), - })); - } - if self.scheme.chars().any(char::is_control) { - return Err(Report::new(TrustedServerError::Proxy { - message: "scheme contains control characters".to_owned(), - })); - } - if let Some(host_header_override) = self.host_header_override { - validate_host_header_override_value(host_header_override).map_err(|reason| { - Report::new(TrustedServerError::Proxy { - message: format!("host header override {reason}"), - }) - })?; - } - - let target_port = self - .port - .unwrap_or_else(|| default_port_for_scheme(self.scheme)); - - let name_base = format!("{}_{}_{}", self.scheme, self.host, target_port); - let host_override_suffix = self - .host_header_override - .map(|host| format!("_oh_{}", sanitize_backend_name_component(host))) - .unwrap_or_default(); - let cert_suffix = if self.certificate_check { - "" - } else { - "_nocert" - }; - let discriminator_suffix = self - .discriminator - .map(|d| format!("_p_{}", sanitize_backend_name_component(d))) - .unwrap_or_default(); - let first_byte_timeout_ms = self.first_byte_timeout.as_millis(); - let between_bytes_timeout_ms = self.between_bytes_timeout.as_millis(); - - // Lossy, human-readable slug for logs. Correctness does not depend on - // it — uniqueness comes from the digest below — so it is bounded to a - // fixed length. Sanitization only emits ASCII, so a char-boundary take - // is byte-exact. - let readable_full = format!( - "{}{}{}{}_fb{}_bb{}", - sanitize_backend_name_component(&name_base), - host_override_suffix, - cert_suffix, - discriminator_suffix, - first_byte_timeout_ms, - between_bytes_timeout_ms - ); - let readable: String = readable_full - .chars() - .take(MAX_READABLE_PREFIX_LEN) - .collect(); - - // Collision-resistant over the *complete* spec, so name equality implies - // spec equality and `NameInUse` reuse is safe. - let digest = spec_digest_hex(&self.canonical_spec_string(target_port)); - let backend_name = format!("backend_{readable}_{digest}"); - - // Bounded by construction; assert it so any future format change fails - // attributably during prediction rather than at Fastly registration. - if backend_name.len() > MAX_BACKEND_NAME_LEN { - return Err(Report::new(TrustedServerError::Proxy { - message: format!( - "backend name exceeds {MAX_BACKEND_NAME_LEN}-char limit ({} chars)", - backend_name.len() - ), - })); - } - - Ok((backend_name, target_port)) + fn predict_backend(&self) -> Result> { + BackendNamingPolicy::Fastly + .predict(&self.platform_spec()) + .change_context(TrustedServerError::Proxy { + message: "backend name prediction failed".to_owned(), + }) } /// Return the deterministic backend name without registering anything. /// - /// Convenience wrapper over `Self::compute_name` that discards the + /// Convenience wrapper over `Self::predict_backend` that discards the /// resolved port, used by [`crate::platform::PlatformBackend`] /// implementations that only need the name for correlation. /// /// # Errors /// /// Returns an error if the host is empty. + #[allow(dead_code, reason = "retained for backend-name parity tests")] pub fn predict_name(self) -> Result> { - self.compute_name().map(|(name, _)| name) + self.predict_backend().map(|prediction| prediction.name) } /// Ensure a dynamic backend exists for this configuration and return its name. /// /// The name is a collision-resistant function of the complete backend spec - /// (see `Self::compute_name`), so different specs — for example, different + /// (see `Self::predict_backend`), so different specs — for example, different /// timeout values — always produce different backend registrations and a /// tight deadline cannot be silently widened by an earlier registration. /// @@ -338,7 +184,9 @@ impl<'a> BackendConfig<'a> { /// Returns an error if the host is empty or if backend creation fails /// (except for `NameInUse` which reuses the existing backend). pub fn ensure(self) -> Result> { - let (backend_name, target_port) = self.compute_name()?; + let prediction = self.predict_backend()?; + let backend_name = prediction.name; + let target_port = prediction.port; let host_with_port = format!("{}:{}", self.host, target_port); @@ -474,6 +322,8 @@ impl<'a> BackendConfig<'a> { #[cfg(test)] mod tests { + use trusted_server_core::platform::BackendNamingError; + use super::{BackendConfig, MAX_BACKEND_NAME_LEN, SPEC_DIGEST_HEX_LEN, compute_host_header}; /// Assert a computed name is `backend__` and stays within @@ -584,8 +434,8 @@ mod tests { .predict_name() .expect_err("should reject host containing newline"); assert!( - err.to_string().contains("control characters"), - "should report control characters in error message" + err.contains::(), + "should preserve the backend naming error report context" ); } @@ -594,10 +444,9 @@ mod tests { let err = BackendConfig::new("https", "") .ensure() .expect_err("should reject empty host"); - let msg = err.to_string(); assert!( - msg.contains("missing host"), - "should report missing host in error message" + err.contains::(), + "should preserve the original backend naming error report context" ); } @@ -617,13 +466,13 @@ mod tests { #[test] fn host_header_overrides_produce_different_names() { - let (name_a, _) = BackendConfig::new("https", "origin.example.com") + let name_a = BackendConfig::new("https", "origin.example.com") .host_header_override(Some("www.example.com")) - .compute_name() + .predict_name() .expect("should compute name with host header override"); - let (name_b, _) = BackendConfig::new("https", "origin.example.com") + let name_b = BackendConfig::new("https", "origin.example.com") .host_header_override(Some("m.example.com")) - .compute_name() + .predict_name() .expect("should compute name with different host header override"); assert_ne!( @@ -648,8 +497,8 @@ mod tests { .expect_err("should reject host header override containing newline"); assert!( - err.to_string().contains("control characters"), - "should report control characters in error message" + err.contains::(), + "should preserve the backend naming error report context" ); } @@ -668,8 +517,8 @@ mod tests { .expect_err("should reject invalid host header override"); assert!( - err.to_string().contains("host header override"), - "should report host header override error for {host_header_override:?}" + err.contains::(), + "should preserve the backend naming error report context for {host_header_override:?}" ); } } @@ -678,13 +527,13 @@ mod tests { fn different_timeouts_produce_different_names() { use std::time::Duration; - let (name_a, _) = BackendConfig::new("https", "origin.example.com") + let name_a = BackendConfig::new("https", "origin.example.com") .first_byte_timeout(Duration::from_secs(2)) - .compute_name() + .predict_name() .expect("should compute name with 2000ms timeout"); - let (name_b, _) = BackendConfig::new("https", "origin.example.com") + let name_b = BackendConfig::new("https", "origin.example.com") .first_byte_timeout(Duration::from_millis(500)) - .compute_name() + .predict_name() .expect("should compute name with 500ms timeout"); assert_ne!( name_a, name_b, @@ -704,13 +553,13 @@ mod tests { fn different_between_bytes_timeouts_produce_different_names() { use std::time::Duration; - let (name_a, _) = BackendConfig::new("https", "origin.example.com") + let name_a = BackendConfig::new("https", "origin.example.com") .between_bytes_timeout(Duration::from_secs(2)) - .compute_name() + .predict_name() .expect("should compute name with 2000ms between-bytes timeout"); - let (name_b, _) = BackendConfig::new("https", "origin.example.com") + let name_b = BackendConfig::new("https", "origin.example.com") .between_bytes_timeout(Duration::from_millis(500)) - .compute_name() + .predict_name() .expect("should compute name with 500ms between-bytes timeout"); assert_ne!( diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9e7920e1c..1c193deb9 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -15,11 +15,12 @@ use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, - PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, - PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, + BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, + PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, + PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, + StoreName, }; // --------------------------------------------------------------------------- @@ -149,6 +150,11 @@ impl PlatformSecretStore for FastlyPlatformSecretStore { /// timeout → unique name). pub struct FastlyPlatformBackend; +#[cfg(test)] +const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; +#[cfg(test)] +const SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; + fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { BackendConfig::new(&spec.scheme, &spec.host) .port(spec.port) @@ -159,85 +165,15 @@ fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { .discriminator(spec.discriminator.as_deref()) } -/// Transport-timeout quantum for auction backends (see -/// [`FastlyPlatformBackend::canonicalize_transport_timeout_ms`]). -const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; - -/// Upper bound of the fine-grained quantum range. -/// -/// Budget-bound values below this ceiling are floored to a -/// [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple (the issue #847 behavior for the -/// default 2000 ms auction). At or above it, values snap to the coarse -/// [`TRANSPORT_TIMEOUT_COARSE_LADDER_MS`] instead so the total number of -/// distinct budget-derived buckets stays globally bounded regardless of how -/// large the configured ceiling is. -const TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS: u32 = 2000; - -/// Coarse rungs for budget-bound transport timeouts below one quantum, -/// ordered high to low. -/// -/// Below one quantum, passing the exact wall-clock remainder through would mint -/// a distinct backend name for every millisecond in `1..250`, so the -/// near-exhausted tail alone could exceed Fastly's per-service dynamic backend -/// limit. Snapping to this finite ladder instead bounds the number of -/// budget-derived names an origin can produce. Budgets below the smallest rung -/// round to zero, which callers treat as "budget exhausted — skip the launch". -const SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; - -/// Coarse rungs for budget-bound transport timeouts at or above the quantum -/// ceiling, ascending. Every rung is a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] -/// multiple. -/// -/// Above [`TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS`], flooring to a 250 ms multiple -/// would let a large configured ceiling (e.g. 60,000 ms) mint hundreds of -/// distinct backend names — recreating the per-service dynamic backend -/// exhaustion this quantization exists to prevent. This fixed, globally finite -/// ladder caps the number of high-budget buckets instead: values are floored to -/// the greatest rung no larger than the remaining budget, and anything above -/// the top rung clamps to it. Rounding down never extends a transport cap past -/// the remaining budget. -/// -/// The rung spacing trades transport window for cardinality: just below a rung -/// the haircut approaches the gap to the rung beneath (worst case ~50%, e.g. a -/// remaining budget of 9,999 ms snaps to 5,000 ms). This is accepted — on the -/// mediator path this value is the effective bound, but a denser ladder would -/// buy back at most half a bucket of transport time at the cost of -/// proportionally more backend names. -const TRANSPORT_TIMEOUT_COARSE_LADDER_MS: [u32; 8] = - [2000, 3000, 5000, 10000, 20000, 30000, 45000, 60000]; - -/// Round a budget-bound transport timeout down to a stable, globally bounded -/// bucket. -/// -/// - At or above [`TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS`], floors to the -/// greatest [`TRANSPORT_TIMEOUT_COARSE_LADDER_MS`] rung no larger than -/// `remaining_ms` (clamping to the top rung above it). -/// - Within the quantum range, floors to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] -/// multiple. -/// - Below one quantum, snaps down to the greatest [`SUB_QUANTUM_LADDER_MS`] -/// rung no larger than `remaining_ms` (or zero). -fn quantize_transport_timeout_ms(remaining_ms: u32) -> u32 { - if remaining_ms >= TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS { - return TRANSPORT_TIMEOUT_COARSE_LADDER_MS - .into_iter() - .rev() - .find(|&rung| rung <= remaining_ms) - .unwrap_or(TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS); - } - let floored = (remaining_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS; - if floored > 0 { - return floored; +impl PlatformBackend for FastlyPlatformBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Fastly } - SUB_QUANTUM_LADDER_MS - .into_iter() - .find(|&rung| rung <= remaining_ms) - .unwrap_or(0) -} -impl PlatformBackend for FastlyPlatformBackend { fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - backend_config_from_spec(spec) - .predict_name() + self.naming_policy() + .predict(spec) + .map(|prediction| prediction.name) .change_context(PlatformError::Backend) } @@ -246,28 +182,6 @@ impl PlatformBackend for FastlyPlatformBackend { .ensure() .change_context(PlatformError::Backend) } - - /// Quantize the transport timeout so budget-derived values do not mint a - /// new dynamic backend name on every request. - /// - /// Fastly embeds the first-byte and between-bytes timeouts in the dynamic - /// backend name (see [`BackendConfig`]) and pools connections per backend - /// name. A per-request wall-clock budget would otherwise defeat that - /// pooling and accumulate registrations toward the per-service dynamic - /// backend limit. - /// - /// A provider's own configured timeout is a constant, so when it is the - /// binding constraint it is returned verbatim — including sub-quantum - /// configured values, which must not be rounded away or the provider could - /// never launch. Only the budget-bound value is snapped to a stable bucket - /// via [`quantize_transport_timeout_ms`]. Rounding down never extends a - /// transport cap past the remaining budget. - fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { - if remaining_ms >= configured_ms { - return configured_ms; - } - quantize_transport_timeout_ms(remaining_ms) - } } // --------------------------------------------------------------------------- @@ -543,6 +457,14 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) /// - [`select`](PlatformHttpClient::select) downcasts each /// [`PlatformPendingRequest`] back to `fastly::PendingRequest` and calls /// `fastly::http::request::select()`. +/// +/// Fastly's Compute HTTP API sends one request to the named backend and returns +/// the origin response; it has no client-side redirect-follow mode. Consequently +/// each trait call below performs exactly one underlying `.send()` or +/// `.send_async()`, and an original 3xx remains visible to core. The host test +/// environment cannot register a real Fastly backend, so the common +/// `StubHttpClient` driver test records the one-send 3xx behavior while adapter +/// tests cover request conversion and the single-send boundary. pub struct FastlyPlatformHttpClient; #[async_trait::async_trait(?Send)] @@ -913,6 +835,40 @@ mod tests { // --- FastlyPlatformHttpClient ------------------------------------------- + #[test] + fn auction_http_capabilities_are_explicit() { + let client = FastlyPlatformHttpClient; + let capabilities = trusted_server_core::platform::AuctionTargetId::Fastly + .descriptor() + .capabilities(); + assert!(client.supports_concurrent_fanout()); + assert!(capabilities.supports_concurrent_provider_fanout()); + assert!(!client.has_enforceable_total_request_deadline()); + assert!( + !capabilities.has_enforceable_total_request_deadline(), + "first-byte and between-byte timers are not a hard total request deadline" + ); + } + + #[test] + fn response_conversion_preserves_original_redirect_at_single_send_boundary() { + let mut response = fastly::Response::from_status(fastly::http::StatusCode::FOUND); + response.set_header("location", "https://redirect.example/next"); + + let platform = fastly_response_to_platform(response, "origin", false, false) + .expect("should convert redirect response"); + + assert_eq!(platform.response.status().as_u16(), 302); + assert_eq!( + platform + .response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()), + Some("https://redirect.example/next") + ); + } + #[test] fn apply_fastly_cache_bypass_sets_pass_when_enabled() { let mut request = fastly::Request::get("https://example.com/"); diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index f2df61744..083760d17 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -297,6 +297,10 @@ mod tests { } impl PlatformBackend for RecordingBackend { + fn naming_policy(&self) -> trusted_server_core::platform::BackendNamingPolicy { + trusted_server_core::platform::BackendNamingPolicy::Fastly + } + fn predict_name( &self, _spec: &PlatformBackendSpec, diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 68f2ddde6..6f64a2f53 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -9,7 +9,9 @@ use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, he use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::auction::{ + AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, +}; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -63,8 +65,10 @@ fn build_state() -> Result, Report> { fn build_state_with_settings( settings: Settings, ) -> Result, Report> { - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -802,6 +806,100 @@ fn build_router(state: &Arc) -> RouterService { mod tests { use super::*; + fn multi_provider_settings() -> Settings { + let mut settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-password" + + [publisher] + domain = "publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse startup test settings"); + settings.auction.enabled = true; + settings.auction.providers = + std::iter::IntoIterator::into_iter(["provider-a", "provider-b"]) + .map(|id| { + ( + id.parse().expect("should parse provider ID"), + trusted_server_core::auction::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "standard".to_string(), + endpoint: format!("https://{id}.example/openrtb"), + timeout_ms: None, + routing: trusted_server_core::auction::RoutingMode::AllEligible, + notifications: + trusted_server_core::auction::NotificationConfig::default(), + profile_config: "{}" + .parse() + .expect("should parse empty profile config object"), + }, + ) + }) + .collect(); + settings + } + + #[test] + fn startup_registers_aps_renderer_route() { + let mut settings = multi_provider_settings(); + settings.auction.providers.clear(); + settings.auction.providers.insert( + "aps-main".parse().expect("should parse APS provider ID"), + trusted_server_core::auction::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: trusted_server_core::auction::RoutingMode::AllEligible, + notifications: trusted_server_core::auction::NotificationConfig::default(), + profile_config: "{\"account_id\":\"example-account\"}" + .parse() + .expect("should parse APS profile config"), + }, + ); + + let state = + build_state_with_settings(settings).expect("Spin startup should register APS renderer"); + assert!( + state.registry.has_route( + &edgezero_core::http::Method::GET, + "/integrations/aps/renderer" + ), + "Spin startup registry should expose the APS renderer" + ); + } + + #[test] + fn disabled_startup_accepts_dormant_multi_provider_auction_plan() { + let mut settings = multi_provider_settings(); + settings.auction.enabled = false; + + build_state_with_settings(settings) + .expect("disabled Spin auction should accept dormant fanout"); + } + + #[test] + fn startup_rejects_multi_provider_auction_plan() { + let error = match build_state_with_settings(multi_provider_settings()) { + Ok(_) => panic!("Spin startup should reject multi-provider fanout"), + Err(error) => error, + }; + assert!( + format!("{error:?}").contains("concurrent provider fanout"), + "should identify unsupported fanout: {error:?}" + ); + } + #[test] fn scheme_host_from_spin_url_extracts_localhost_with_port() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..37c6f4313 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -5,20 +5,18 @@ use std::time::Duration; use bytes::Bytes; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; -use error_stack::Report; +use error_stack::{Report, ResultExt as _}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use http_body_util::BodyExt as _; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, - PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, - RuntimeServices, StoreId, StoreName, UnavailableKvStore, + BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, + PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, }; #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] use trusted_server_core::platform::UnavailableHttpClient; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use error_stack::ResultExt as _; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] use std::io::Read as _; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] @@ -82,27 +80,15 @@ impl PlatformSecretStore for NoopSecretStore { struct NoopBackend; impl PlatformBackend for NoopBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Spin + } + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - let port = spec - .port - .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); - let timeout_ms = spec.first_byte_timeout.as_millis(); - let cert_suffix = if spec.certificate_check { - "" - } else { - "_nocert" - }; - // Keep two providers that share an origin on distinct names so auction - // response correlation cannot cross providers. - let discriminator = spec - .discriminator - .as_deref() - .map(|d| format!("_p_{d}")) - .unwrap_or_default(); - Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", - spec.scheme, spec.host, port - )) + self.naming_policy() + .predict(spec) + .map(|prediction| prediction.name) + .change_context(PlatformError::Backend) } fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { @@ -462,6 +448,13 @@ struct SpinPendingResponse { /// request launches. `select` keeps a defense-in-depth rejection for more /// than one pending request, matching the Cloudflare adapter behavior. /// +/// Spin's WASI HTTP API sends one request and returns the original response; it +/// exposes no redirect-follow policy. Each trait call therefore reaches exactly +/// one `spin_sdk::http::send` boundary and returns an original 3xx to core. Host +/// tests cannot instantiate Spin's WASI transport, so the common +/// `StubHttpClient` driver records the one-send 3xx behavior while adapter tests +/// cover request/response policy around that single boundary. +/// /// # Known MVP limits /// /// **No configurable outbound timeout.** `spin_sdk::http::send` does not @@ -794,6 +787,17 @@ mod tests { use super::*; use edgezero_core::body::Body; + use trusted_server_core::platform::AuctionTargetId; + + #[test] + fn auction_http_capabilities_are_explicit() { + let capabilities = AuctionTargetId::Spin.descriptor().capabilities(); + assert!(!capabilities.supports_concurrent_provider_fanout()); + assert!( + !capabilities.has_enforceable_total_request_deadline(), + "Spin outbound HTTP does not expose an enforceable hard total request deadline" + ); + } use edgezero_core::context::RequestContext; use edgezero_core::http::request_builder; use edgezero_core::params::PathParams; @@ -845,6 +849,29 @@ mod tests { apply_spin_response_policy(&edgezero_core::http::Method::GET, 200, headers, body) } + #[test] + fn response_policy_preserves_original_redirect_at_single_send_boundary() { + let (headers, body) = apply_spin_response_policy( + &edgezero_core::http::Method::GET, + 302, + vec![( + "location".to_string(), + b"https://redirect.example/next".to_vec(), + )], + Vec::new(), + ) + .expect("should preserve redirect response"); + + assert_eq!( + headers, + vec![( + "location".to_string(), + b"https://redirect.example/next".to_vec(), + )] + ); + assert!(body.is_empty()); + } + #[test] fn extract_client_ip_reads_spin_request_context() { let mut req = request_builder() diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 802d854ce..abc545926 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -559,7 +559,6 @@ mod tests { r#" [integrations.prebid] enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-old.js" [integrations.prebid.bundle] @@ -595,7 +594,6 @@ user_id_modules = ["sharedIdSystem", "uid2IdSystem"] r#" [integrations.prebid] enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" [integrations.prebid.bundle] adapters = ["rubicon"] @@ -626,7 +624,6 @@ adapters = ["rubicon"] r#" [integrations.prebid] enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" "#, ); @@ -646,7 +643,6 @@ server_url = "https://prebid.example.com/openrtb2/auction" r#" [integrations.prebid] enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" [integrations.prebid.bundle] adapters = [] @@ -667,7 +663,6 @@ adapters = [] r#" [integrations.prebid] enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" [integrations.prebid.bundle] adapters = ["rubicon", 123] diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index 3a3b9dffd..1d3924e8d 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -28,6 +28,8 @@ ids = ["trusted_server_config"] ids = ["trusted_server_secrets"] "#; const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES"; +const PROVIDER_ENDPOINT_ENV: &str = "TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__ENDPOINT"; +const BIDDER_PROVIDER_ENV: &str = "TRUSTED_SERVER__AUCTION__BIDDERS__EXAMPLE-BIDDER__PROVIDER"; struct MigratedProject { directory: TempDir, @@ -35,7 +37,7 @@ struct MigratedProject { manifest_path: std::path::PathBuf, } -fn migrated_legacy_project() -> MigratedProject { +fn migrated_project() -> MigratedProject { let directory = tempfile::tempdir().expect("should create temporary config directory"); let config_path = directory.path().join("trusted-server.toml"); let manifest_path = directory.path().join("edgezero.toml"); @@ -43,6 +45,13 @@ fn migrated_legacy_project() -> MigratedProject { .parse::() .expect("should parse legacy integration config"); document["auction"]["rewrite_creatives"] = value(true); + document["auction"]["providers"]["pbs-main"] = toml_edit::table(); + document["auction"]["providers"]["pbs-main"]["protocol"] = value("openrtb-2.6"); + document["auction"]["providers"]["pbs-main"]["profile"] = value("standard"); + document["auction"]["providers"]["pbs-main"]["endpoint"] = + value("https://original.example/openrtb2/auction"); + document["auction"]["bidders"]["example-bidder"] = toml_edit::table(); + document["auction"]["bidders"]["example-bidder"]["provider"] = value("pbs-main"); fs::write(&config_path, document.to_string()).expect("should write migrated config"); fs::write(&manifest_path, MANIFEST).expect("should write test manifest"); MigratedProject { @@ -65,8 +74,8 @@ fn validate_with_overlay(project: &MigratedProject, raw_value: &str) -> Output { } #[test] -fn migrated_legacy_config_applies_rewrite_creatives_environment_override() { - let project = migrated_legacy_project(); +fn map_config_applies_rewrite_creatives_environment_override() { + let project = migrated_project(); let output = Command::new(env!("CARGO_BIN_EXE_ts")) .args(["config", "push", "--adapter", "axum", "--manifest"]) .arg(&project.manifest_path) @@ -108,8 +117,8 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() { } #[test] -fn migrated_legacy_config_default_rewrite_creatives_has_no_local_diff() { - let project = migrated_legacy_project(); +fn map_config_default_rewrite_creatives_has_no_local_diff() { + let project = migrated_project(); let push = Command::new(env!("CARGO_BIN_EXE_ts")) .args(["config", "push", "--adapter", "axum", "--manifest"]) .arg(&project.manifest_path) @@ -178,8 +187,8 @@ fn migrated_legacy_config_default_rewrite_creatives_has_no_local_diff() { } #[test] -fn migrated_legacy_config_rejects_invalid_rewrite_creatives_environment_override() { - let project = migrated_legacy_project(); +fn map_config_rejects_invalid_rewrite_creatives_environment_override() { + let project = migrated_project(); let output = validate_with_overlay(&project, "not-a-boolean"); let stderr = String::from_utf8_lossy(&output.stderr); @@ -192,3 +201,52 @@ fn migrated_legacy_config_rejects_invalid_rewrite_creatives_environment_override "error should identify the invalid boolean overlay: {stderr}" ); } + +#[test] +fn map_shaped_provider_and_bidder_environment_overlays_apply() { + let project = migrated_project(); + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .args(["config", "push", "--adapter", "axum", "--manifest"]) + .arg(&project.manifest_path) + .arg("--app-config") + .arg(&project.config_path) + .args(["--yes", "--no-diff"]) + .current_dir(project.directory.path()) + .env( + PROVIDER_ENDPOINT_ENV, + "https://overlay.example/openrtb2/auction", + ) + .env(BIDDER_PROVIDER_ENV, "pbs-main") + .output() + .expect("should run ts config push with map overlays"); + + assert!( + output.status.success(), + "map-shaped overlays should push successfully: {}", + String::from_utf8_lossy(&output.stderr) + ); + let local_store_path = project + .directory + .path() + .join(".edgezero/local-config-trusted_server_config.json"); + let local_store: serde_json::Value = serde_json::from_str( + &fs::read_to_string(local_store_path).expect("should read pushed local config"), + ) + .expect("should parse local config store"); + let envelope_json = local_store + .as_object() + .and_then(|entries| entries.values().next()) + .and_then(serde_json::Value::as_str) + .expect("should contain a blob envelope"); + let envelope: serde_json::Value = + serde_json::from_str(envelope_json).expect("should parse blob envelope"); + + assert_eq!( + envelope["data"]["auction"]["providers"]["pbs-main"]["endpoint"], + "https://overlay.example/openrtb2/auction" + ); + assert_eq!( + envelope["data"]["auction"]["bidders"]["example-bidder"]["provider"], + "pbs-main" + ); +} diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index eb1e99e2c..c943738f8 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -410,78 +410,71 @@ Manages the execution of an auction flow, coordinates providers, and collects re ## Auction Strategies -### 1. Parallel + Mediation (Recommended) -**Use case:** Header bidding with ad server mediation +### 1. Parallel + Mediation ```toml [auction] enabled = true -providers = ["prebid", "aps"] -mediator = "adserver_mock" # Setting mediator enables parallel mediation strategy timeout_ms = 2000 +mediator = "adserver_mock" + +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" +profile_config = { account_id = "example-aps-account" } ``` -**Flow:** -1. Prebid and APS run in parallel -2. Both return their bids simultaneously -3. Bids are sent to the mediator for final decision -4. Mediator competes house inventory and returns winning creative +Providers run in parallel, then the separately registered mediator chooses from +decoded-price bids. ### 2. Parallel Only -**Use case:** Client-side auction, no mediation -```toml -[auction] -enabled = true -providers = ["prebid", "aps"] -# No mediator = parallel only strategy (highest CPM wins) -timeout_ms = 2000 -``` - -**Flow:** -1. All providers run in parallel -2. Highest bid wins -3. No mediation server involved +Omit `mediator` from the same map-shaped configuration. The orchestrator selects +the highest decoded CPM per slot and applies floors locally. ## Configuration -### Configuration - -All auction settings are configured directly under `[auction]`: +`[auction.providers.]` is the only bidder-provider inventory. +`[auction.bidders.]` maps a client-visible bidder to exactly one +provider. The mediator is selected separately by `[auction].mediator`. ```toml [auction] -enabled = true # Enable/disable auction orchestration -providers = ["prebid", "aps"] # List of bidder providers -mediator = "adserver_mock" # Optional: if set, uses mediation; if omitted, highest bid wins -timeout_ms = 2000 # Overall auction timeout -``` - -**Strategy Auto-Detection:** -- When `mediator` is configured → Runs **parallel mediation** (providers in parallel, mediator decides winner) -- When `mediator` is omitted → Runs **parallel only** (providers in parallel, highest CPM wins) - -### Provider Configuration +enabled = true +timeout_ms = 2000 -Each provider has its own configuration section: +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 900 +routing = "explicit" -```toml -[integrations.prebid] -enabled = true -server_url = "https://prebid-server.example.com" -timeout_ms = 1000 +[auction.providers.pbs-main.profile_config] +debug = false +test_mode = false +consent_forwarding = "both" -[integrations.aps] -enabled = true -mock = true # Set to false for real integration -timeout_ms = 800 +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = ["example-seat"] -[integrations.adserver_mock] -enabled = true -endpoint = "http://localhost:6767/adserver/mediate" -timeout_ms = 500 +[auction.bidders.example-server] +provider = "pbs-main" ``` +Provider IDs own backend correlation and response identity. The configured +profile supplies typed OpenRTB behavior. Common endpoint, timeout, routing, and +notification policy do not belong to browser integration configuration. + ## Adding a New Provider 1. Create a new file in `src/auction/providers/your_provider.rs` diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c4af6fd3d..ca6b07c64 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -178,6 +178,32 @@ pub async fn handle_auction( }; let consent_context = ec_context.consent().clone(); + if !orchestrator.is_enabled() { + log::info!("/auction: auction is disabled; returning no-bid response"); + let auction_request = convert_tsjs_to_auction_request( + &body, + settings, + services, + &http_req, + consent_context, + ec_id, + None, + )?; + let empty_result = OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + }; + return convert_to_openrtb_response( + &empty_result, + settings, + &auction_request, + ec_context.ec_allowed(), + ); + } + // Server-side auction consent gate. The publisher-navigation and // `/_ts/page-bids` paths fail closed for GDPR/unknown jurisdictions that // lack effective TCF Purpose 1. `/auction` is the programmatic entry point @@ -292,6 +318,7 @@ pub async fn handle_auction( settings, request: &http_req, timeout_ms: settings.auction.timeout_ms, + transport_timeout_ms: settings.auction.timeout_ms, provider_responses: None, services, }; @@ -646,7 +673,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for PanicOnBidProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "panic_provider" } @@ -684,7 +711,7 @@ mod tests { let settings = create_test_settings(); let config = AuctionConfig { enabled: true, - providers: vec!["panic_provider".to_string()], + providers: AuctionConfig::legacy_provider_map(&["panic_provider"]), timeout_ms: 2000, mediator: None, ..Default::default() @@ -768,7 +795,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for EidCapturingProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "eid_capturing_provider" } @@ -812,7 +839,7 @@ mod tests { let settings = create_test_settings(); let config = AuctionConfig { enabled: true, - providers: vec!["eid_capturing_provider".to_string()], + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), timeout_ms: 2000, mediator: None, ..Default::default() diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index a011e694d..bc390f35f 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -542,6 +542,10 @@ pub(crate) fn convert_to_openrtb_response_with_report( #[cfg(test)] mod tests { use super::*; + use crate::auction::plan::{ + AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, + }; + use crate::auction::routing::route_auction; use crate::auction::types::{ ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, }; @@ -550,7 +554,8 @@ mod tests { use crate::test_support::tests::create_test_settings; use http::Method; use serde_json::json; - use std::collections::HashSet; + use std::collections::{BTreeMap, HashSet}; + use std::str::FromStr as _; fn make_request() -> Request { Request::builder() @@ -565,6 +570,28 @@ mod tests { create_test_settings() } + fn single_prebid_plan() -> AuctionPlan { + AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 900, + providers: BTreeMap::from([( + ProviderId::from_str("pbs-primary").expect("should parse provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "prebid-server".to_string(), + endpoint: "https://pbs.example.test/openrtb".to_string(), + timeout_ms: None, + routing: RoutingMode::Explicit, + notifications: NotificationConfig::default(), + profile_config: json!({}), + }, + )]), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, + }) + .expect("should compile plan") + } + fn make_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-1".to_string(), @@ -612,6 +639,7 @@ mod tests { creative: Some("
Ad
".to_string()), adomain: Some(vec!["advertiser.example.com".to_string()]), bidder: bidder.to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -705,6 +733,31 @@ mod tests { .expect("should convert banner request") } + #[test] + fn canonical_tsjs_request_without_bids_feeds_stored_request_router() { + let body = AdRequest { + ad_units: vec![AdUnit { + code: "stored-slot".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250]], + }), + }), + bids: None, + }], + config: None, + eids: None, + }; + let request = convert_body_to_auction_request(&body, &make_settings()); + let routed = route_auction(request, &make_request(), &single_prebid_plan(), None); + + assert_eq!(routed.inputs().len(), 1); + assert!( + routed.inputs()[0].slots()[0].has_trusted_stored_request(), + "canonical empty bidder map should preserve stored-request intent" + ); + } + #[test] fn response_serializes_prebid_immediate_no_bid_without_error_metadata() { let request = make_auction_request(); @@ -1514,6 +1567,7 @@ mod tests { "should omit adm for renderer bids" ); assert_eq!(bid["id"], json!("fictional-bid")); + assert_eq!(json["seatbid"][0]["seat"], json!("aps")); assert_eq!(bid["adid"], json!("fictional-ad")); assert_eq!(bid["crid"], json!("fictional-creative")); assert_eq!( diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 986beb984..432303928 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -17,8 +17,12 @@ pub mod config; pub mod context; pub mod endpoints; pub mod formats; +pub(crate) mod openrtb; pub mod orchestrator; +pub mod plan; +pub(crate) mod profile; pub mod provider; +pub(crate) mod routing; pub mod telemetry; #[cfg(test)] pub(crate) mod test_support; @@ -27,6 +31,10 @@ pub mod types; pub use config::AuctionConfig; pub use context::{ContextQueryParams, ContextValue, build_url_with_context_params}; pub use orchestrator::AuctionOrchestrator; +pub use plan::{ + AuctionPlan, BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, + RoutingMode, +}; pub use provider::AuctionProvider; pub use telemetry::{ AbandonedProviderCall, AuctionEventBatch, AuctionEventRow, AuctionObservationContext, @@ -37,132 +45,189 @@ pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, }; -/// Type alias for provider builder functions. -type ProviderBuilder = - fn(&Settings) -> Result>, Report>; - -/// Returns the list of all available provider builder functions. +/// Compile the canonical target-independent auction plan for [`Settings`]. /// -/// This list is used to auto-discover and register auction providers from settings. -/// Each builder function checks the settings for its specific provider configuration -/// and returns any enabled providers. -fn provider_builders() -> &'static [ProviderBuilder] { - &[ - crate::integrations::prebid::register_auction_provider, - crate::integrations::aps::register_providers, - crate::integrations::adserver_mock::register_providers, - ] +/// This is the single settings-to-plan boundary used by deploy validation, +/// adapter startup, and operator tooling. Global request signing remains owned +/// by [`Settings`] and is copied into compiler input only at this boundary. +/// +/// # Errors +/// +/// Returns an error when auction provider, bidder route, signing, or mediator +/// configuration is invalid. +pub fn compile_auction_plan( + settings: &Settings, +) -> Result> { + AuctionPlan::compile(plan::AuctionPlanConfig { + timeout_ms: settings.auction.timeout_ms, + providers: settings.auction.providers.clone(), + bidders: settings.auction.bidders.clone(), + mediator: settings.auction.mediator.clone(), + request_signing: settings.request_signing.clone(), + }) + .map(|plan| plan.with_enabled(settings.auction.enabled)) } -/// Build a new auction orchestrator for the current settings. +/// Build a new auction orchestrator from one shared compiled plan. /// /// This constructor registers all auction providers discovered from the provided settings. /// Callers can reuse the returned [`AuctionOrchestrator`] across requests. /// /// # Arguments -/// * `settings` - Application settings used to configure the orchestrator and providers +/// * `plan` - Shared immutable compiled plan +/// * `settings` - Application settings used only for the separately registered mediator /// /// # Errors /// /// Returns an error when an enabled auction provider has invalid configuration. -pub fn build_orchestrator( +pub fn build_orchestrator_with_plan( + plan: Arc, settings: &Settings, ) -> Result> { - log::info!("Building auction orchestrator"); - - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - - // Auto-discover and register all auction providers from settings - for builder in provider_builders() { - for provider in builder(settings)? { - orchestrator.register_provider(provider); - } - } - - orchestrator.validate_configured_provider_names()?; + log::info!("Building plan-backed auction orchestrator"); + + let mediator = if let Some(expected_id) = plan.mediator() { + let provider = crate::integrations::adserver_mock::register_providers(settings)? + .into_iter() + .find(|provider| provider.provider_name() == expected_id) + .ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!( + "auction mediator `{expected_id}` must reference a separately registered enabled integration with the exact same ID" + ), + }) + })?; + Some(provider) + } else { + None + }; + let orchestrator = AuctionOrchestrator::from_plan(plan, mediator); log::info!( - "Auction orchestrator built with {} providers", + "Auction orchestrator built with {} bidder providers", orchestrator.provider_count() ); Ok(orchestrator) } +/// Test convenience constructor that compiles a plan before construction. +/// +/// # Errors +/// +/// Returns an error when plan compilation or mediator construction fails. #[cfg(test)] -mod tests { - use crate::settings::Settings; - use crate::test_support::tests::crate_test_settings_str; +pub fn build_orchestrator( + settings: &Settings, +) -> Result> { + let plan = Arc::new(compile_auction_plan(settings)?); + build_orchestrator_with_plan(plan, settings) +} - use super::build_orchestrator; +#[cfg(test)] +mod plan_sharing_tests { + use super::*; + use crate::integrations::IntegrationRegistry; + use crate::test_support::tests::create_test_settings; - fn settings_with_auction_config(auction_config: &str) -> Settings { - let settings_str = format!("{}\n{auction_config}", crate_test_settings_str()); - let mut settings = Settings::from_toml(&settings_str) - .expect("should parse auction provider validation test settings"); - settings.proxy.allowed_domains = vec!["*.example".to_string(), "*.example.com".to_string()]; - settings + #[test] + fn orchestrator_and_registry_share_the_compiled_plan_allocation() { + let settings = create_test_settings(); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings) + .expect("should build orchestrator"); + let registry = IntegrationRegistry::with_plan(&settings, Arc::clone(&plan)) + .expect("should build integration registry"); + + assert!(orchestrator.shares_plan(&plan)); + assert!(registry.shares_plan(&plan)); } - fn assert_orchestrator_error_contains(settings: &Settings, expected: &str) { - let Err(err) = build_orchestrator(settings) else { - panic!("build_orchestrator should reject invalid auction providers"); - }; - assert!( - err.to_string().contains(expected), - "should include expected validation message: {expected}" - ); + #[test] + fn configured_mediator_requires_enabled_exact_registration() { + for mediator_config in [None, Some(serde_json::json!({"enabled": false}))] { + let mut settings = create_test_settings(); + settings.auction.mediator = Some("adserver_mock".to_string()); + if let Some(config) = mediator_config { + settings + .integrations + .insert_config("adserver_mock", &config) + .expect("should insert mediator config"); + } else { + settings.integrations.remove("adserver_mock"); + } + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile plan")); + + let error = match build_orchestrator_with_plan(plan, &settings) { + Ok(_) => panic!("should require enabled mediator registration"), + Err(error) => error, + }; + assert!(error.to_string().contains("adserver_mock")); + } } #[test] - fn configured_unregistered_provider_fails_startup() { - let settings = settings_with_auction_config( - r#" - [auction] - enabled = true - providers = ["missing-provider"] - timeout_ms = 2000 - "#, - ); - - assert_orchestrator_error_contains( - &settings, - "Auction provider `missing-provider` is listed in [auction] but no enabled integration provides it", - ); + fn configured_mediator_builds_when_exact_registration_is_enabled() { + let mut settings = create_test_settings(); + settings.auction.mediator = Some("adserver_mock".to_string()); + settings + .integrations + .insert_config( + "adserver_mock", + &serde_json::json!({ + "enabled": true, + "endpoint": "https://mediator.example/mediate" + }), + ) + .expect("should insert mediator config"); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile plan")); + + build_orchestrator_with_plan(plan, &settings) + .expect("should build with enabled exact mediator registration"); } #[test] - fn mixed_registered_and_unregistered_providers_fail_startup() { - let settings = settings_with_auction_config( - r#" - [auction] - enabled = true - providers = ["prebid", "missing-provider"] - timeout_ms = 2000 - "#, - ); - - assert_orchestrator_error_contains( - &settings, - "Auction provider `missing-provider` is listed in [auction] but no enabled integration provides it", - ); + fn cloudflare_and_spin_reject_multi_provider_plans_before_runtime_construction() { + let mut settings = create_test_settings(); + settings.auction.enabled = true; + settings.auction.providers = + AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]); + let plan = compile_auction_plan(&settings).expect("should compile target-independent plan"); + + for target in [ + crate::platform::AuctionTargetId::Cloudflare, + crate::platform::AuctionTargetId::Spin, + ] { + let error = plan + .validate_for_target(target) + .expect_err("should reject unsupported multi-provider fanout"); + assert!( + error + .to_string() + .contains("does not support concurrent provider fanout") + ); + } } #[test] - fn configured_unregistered_mediator_fails_startup() { - let settings = settings_with_auction_config( - r#" - [auction] - enabled = true - providers = ["prebid"] - mediator = "missing-mediator" - timeout_ms = 2000 - "#, - ); - - assert_orchestrator_error_contains( - &settings, - "Auction provider `missing-mediator` is listed in [auction] but no enabled integration provides it", - ); + fn aps_profile_registers_renderer_without_browser_aps_config() { + let mut settings = create_test_settings(); + settings.auction.providers = std::collections::BTreeMap::from([( + "aps-main".parse().expect("should parse APS provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({"account_id":"example-account"}), + }, + )]); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile APS plan")); + let registry = IntegrationRegistry::with_plan(&settings, plan) + .expect("should build APS renderer registry"); + + assert!(registry.has_route(&http::Method::GET, "/integrations/aps/renderer")); } } diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs new file mode 100644 index 000000000..180e9f15c --- /dev/null +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -0,0 +1,758 @@ +//! Shared `OpenRTB` 2.6 request/response support for config-first providers. +//! +//! Profiles receive only routed, privacy-approved facts and never the raw +//! downstream request or unrestricted runtime services. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use error_stack::Report; +use serde_json::{Map, Value, json}; +use url::Url; + +use super::plan::{NotificationPolicy, ProviderPlan}; +use super::profile::{ + ApsProfilePlan, CompiledOpenRtbProfile, PrebidProfilePlan, StandardProfilePlan, +}; +use super::routing::{ + PrebidTransportHeaders, ProviderAuctionInput, ProviderSlotInput, RoutedAuction, +}; +use super::types::{AuctionResponse, Bid}; +use crate::consent::ConsentSource; +use crate::error::TrustedServerError; +use crate::openrtb::{ + Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, + RegsExt, Site, ToExt as _, TrustedServerExt, User, UserExt, to_openrtb_i32, +}; +use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; + +const DEFAULT_CURRENCY: &str = "USD"; +const APS_SDK_SOURCE: &str = "prebid"; +const APS_SDK_VERSION: &str = "2.2.0"; +const MAX_CONSERVATIVE_LANGUAGE_BYTES: usize = 8; + +/// Result of request construction before transport. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub(crate) enum OpenRtbBuildOutcome { + Ready(OpenRtbRequest), + NoImpressions, +} + +/// Explicit, deterministic signing input. No signer is loaded by this driver. +pub(crate) struct RequestFinalization<'a> { + pub(crate) signer: Option<&'a RequestSigner>, + pub(crate) signing_params: SigningParams, +} + +/// Build one provider request from its immutable routed input. +/// +/// # Errors +/// +/// Returns an auction error when static/profile extensions cannot be merged or +/// the supplied signing input does not bind the already-fixed request ID. +pub(crate) fn build_request( + input: &ProviderAuctionInput, + routed: &RoutedAuction, + provider: &ProviderPlan, + effective_timeout_ms: u32, + finalization: &RequestFinalization<'_>, +) -> Result> { + let policy = ProfilePolicy::from(&provider.profile); + let mut request = build_common_request(input, routed, policy, effective_timeout_ms); + if request.imp.is_empty() { + return Ok(OpenRtbBuildOutcome::NoImpressions); + } + policy.augment_request(&mut request, input, routed)?; + finalize_request(&mut request, policy, finalization)?; + Ok(OpenRtbBuildOutcome::Ready(request)) +} + +#[derive(Clone, Copy)] +enum ProfilePolicy<'a> { + Standard(&'a StandardProfilePlan), + Prebid(&'a PrebidProfilePlan), + Aps(&'a ApsProfilePlan), +} + +impl<'a> From<&'a CompiledOpenRtbProfile> for ProfilePolicy<'a> { + fn from(profile: &'a CompiledOpenRtbProfile) -> Self { + match profile { + CompiledOpenRtbProfile::Standard(plan) => Self::Standard(plan), + CompiledOpenRtbProfile::PrebidServer(plan) => Self::Prebid(plan), + CompiledOpenRtbProfile::Aps(plan) => Self::Aps(plan), + } + } +} + +impl ProfilePolicy<'_> { + fn augment_request( + self, + request: &mut OpenRtbRequest, + input: &ProviderAuctionInput, + routed: &RoutedAuction, + ) -> Result<(), Report> { + match self { + Self::Standard(plan) => apply_standard(request, plan), + Self::Prebid(plan) => apply_prebid(request, input, routed, plan), + Self::Aps(plan) => apply_aps(request, plan), + } + } + + fn keeps_pbs_identity_when_unsigned(self) -> bool { + matches!(self, Self::Prebid(_)) + } +} + +fn build_common_request( + input: &ProviderAuctionInput, + routed: &RoutedAuction, + policy: ProfilePolicy<'_>, + effective_timeout_ms: u32, +) -> OpenRtbRequest { + let common = input.common_request(); + let imps = input + .slots() + .iter() + .filter_map(|slot| build_imp(slot, policy)) + .collect(); + let site_domain = match policy { + ProfilePolicy::Aps(plan) => plan + .inventory_domain + .clone() + .unwrap_or_else(|| common.publisher.domain.clone()), + _ => common.publisher.domain.clone(), + }; + let page = match policy { + ProfilePolicy::Aps(plan) => { + aps_inventory_page(plan, common.publisher.page_url.as_deref(), &site_domain) + } + ProfilePolicy::Prebid(plan) => common.publisher.page_url.as_deref().map(|page| { + plan.debug_query_params.as_deref().map_or_else( + || page.to_string(), + |query| append_query_fragment(page, query), + ) + }), + ProfilePolicy::Standard(_) => common.publisher.page_url.clone(), + }; + let consent = common.user.consent.as_ref(); + let body_consent = match policy { + ProfilePolicy::Prebid(plan) => consent.filter(|value| { + plan.consent_forwarding.includes_body_consent() + || !matches!(value.source, ConsentSource::Cookie) + }), + _ => consent, + }; + let raw_tc = body_consent.and_then(|value| value.raw_tc_string.clone()); + let user = Some(User { + id: common.user.id.clone(), + consent: raw_tc.clone(), + ext: UserExt { + consent: raw_tc, + consented_providers_settings: matches!(policy, ProfilePolicy::Prebid(_)) + .then(|| { + body_consent + .and_then(|value| value.raw_ac_string.clone()) + .map(|consented_providers| ConsentedProvidersSettings { + consented_providers: Some(consented_providers), + }) + }) + .flatten(), + eids: common.user.eids.clone(), + } + .to_ext(), + ..Default::default() + }); + let language = normalized_language(routed.prebid_transport_headers(), policy); + let device = common + .device + .as_ref() + .map(|device| Device { + ua: device.user_agent.clone(), + ip: device.ip.clone(), + geo: device.geo.as_ref().map(|geo| Geo { + country: Some(geo.country.clone()), + region: geo.region.clone(), + city: Some(geo.city.clone()), + lat: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.latitude), + lon: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.longitude), + metro: (geo.metro_code > 0).then(|| geo.metro_code.to_string()), + r#type: Some(2), + ..Default::default() + }), + dnt: routed.dnt(), + language: language.clone(), + ..Default::default() + }) + .or_else(|| { + (routed.dnt().is_some() || language.is_some()).then_some(Device { + dnt: routed.dnt(), + language, + ..Default::default() + }) + }); + + OpenRtbRequest { + id: Some(common.id.clone()), + imp: imps, + site: Some(Site { + domain: Some(site_domain.clone()), + page, + r#ref: matches!(policy, ProfilePolicy::Prebid(_)) + .then(|| header_string(routed.prebid_transport_headers().referer())) + .flatten(), + publisher: Some(Publisher { + domain: Some(site_domain), + ..Default::default() + }), + ..Default::default() + }), + user, + device, + regs: build_regs(body_consent, policy), + test: match policy { + ProfilePolicy::Prebid(plan) => plan.test_mode.then_some(true), + _ => None, + }, + tmax: to_openrtb_i32( + effective_timeout_ms, + "tmax", + "config-first provider request", + ), + cur: vec![DEFAULT_CURRENCY.to_string()], + ..Default::default() + } +} + +fn build_imp(slot: &ProviderSlotInput, policy: ProfilePolicy<'_>) -> Option { + let formats = slot + .slot() + .formats + .iter() + .filter_map(|format| { + Some(Format { + w: to_openrtb_i32(format.width, "format.w", "routed slot"), + h: to_openrtb_i32(format.height, "format.h", "routed slot"), + ..Default::default() + }) + .filter(|value| value.w.is_some() && value.h.is_some()) + }) + .collect::>(); + let first_width = formats.first()?.w; + let first_height = formats.first()?.h; + let aps_banner = matches!(policy, ProfilePolicy::Aps(_)); + Some(Imp { + id: Some(slot.slot().id.clone()), + banner: Some(Banner { + format: formats, + w: aps_banner.then_some(first_width).flatten(), + h: aps_banner.then_some(first_height).flatten(), + topframe: aps_banner.then_some(false), + ..Default::default() + }), + tagid: matches!(policy, ProfilePolicy::Prebid(_)).then(|| slot.slot().id.clone()), + bidfloor: slot.slot().floor_price, + bidfloorcur: slot + .slot() + .floor_price + .map(|_| DEFAULT_CURRENCY.to_string()), + secure: Some(true), + ..Default::default() + }) +} + +fn apply_standard( + request: &mut OpenRtbRequest, + plan: &StandardProfilePlan, +) -> Result<(), Report> { + request.ext = nonempty_map(plan.request_ext.as_object().clone()); + for imp in &mut request.imp { + imp.ext = nonempty_map(plan.imp_ext.as_object().clone()); + } + Ok(()) +} + +fn apply_prebid( + request: &mut OpenRtbRequest, + input: &ProviderAuctionInput, + _routed: &RoutedAuction, + plan: &PrebidProfilePlan, +) -> Result<(), Report> { + for (imp, slot) in request.imp.iter_mut().zip(input.slots()) { + let bidder = slot + .bidder_params() + .iter() + .map(|(bidder, params)| { + let mut params = params.clone(); + plan.override_engine + .apply_routed(bidder.as_str(), slot.prebid_zone(), &mut params); + (bidder.as_str().to_string(), params) + }) + .collect::>(); + let mut prebid = Map::new(); + if !bidder.is_empty() { + prebid.insert("bidder".to_string(), Value::Object(bidder)); + } else if slot.has_trusted_stored_request() { + prebid.insert("storedrequest".to_string(), json!({"id": slot.slot().id})); + } + imp.ext = Some(Map::from_iter([( + "prebid".to_string(), + Value::Object(prebid), + )])); + } + let mut prebid_request = Map::new(); + if plan.debug { + prebid_request.insert("debug".to_string(), Value::Bool(true)); + prebid_request.insert("returnallbidstatus".to_string(), Value::Bool(true)); + } + request.ext = Some(Map::from_iter([( + "prebid".to_string(), + Value::Object(prebid_request), + )])); + Ok(()) +} + +fn apply_aps( + request: &mut OpenRtbRequest, + plan: &ApsProfilePlan, +) -> Result<(), Report> { + request.ext = Some(Map::from_iter([ + ( + "account".to_string(), + Value::String(plan.account_id.clone()), + ), + ( + "sdk".to_string(), + json!({"source": APS_SDK_SOURCE, "version": APS_SDK_VERSION}), + ), + ])); + Ok(()) +} + +fn finalize_request( + request: &mut OpenRtbRequest, + policy: ProfilePolicy<'_>, + finalization: &RequestFinalization<'_>, +) -> Result<(), Report> { + let request_id = request.id.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Auction { + message: "OpenRTB request ID must be fixed before signing".to_string(), + }) + })?; + if request_id != finalization.signing_params.request_id { + return Err(Report::new(TrustedServerError::Auction { + message: "OpenRTB signing params do not bind the fixed request ID".to_string(), + })); + } + let trusted_server = if let Some(signer) = finalization.signer { + let signature = signer.sign_request(&finalization.signing_params)?; + Some(TrustedServerExt { + version: Some(SIGNING_VERSION.to_string()), + signature: Some(signature), + kid: Some(signer.kid.clone()), + request_host: Some(finalization.signing_params.request_host.clone()), + request_scheme: Some(finalization.signing_params.request_scheme.clone()), + ts: Some(finalization.signing_params.timestamp), + }) + } else if policy.keeps_pbs_identity_when_unsigned() { + Some(TrustedServerExt { + version: None, + signature: None, + kid: None, + request_host: Some(finalization.signing_params.request_host.clone()), + request_scheme: Some(finalization.signing_params.request_scheme.clone()), + ts: None, + }) + } else { + None + }; + if let Some(trusted_server) = trusted_server { + let ext = request.ext.get_or_insert_with(Map::new); + let serialized = serde_json::to_value(trusted_server).map_err(|error| { + Report::new(TrustedServerError::Auction { + message: format!("Failed to serialize Trusted Server extension: {error}"), + }) + })?; + ext.insert("trusted_server".to_string(), serialized); + } + Ok(()) +} + +fn build_regs( + consent: Option<&crate::consent::ConsentContext>, + policy: ProfilePolicy<'_>, +) -> Option { + let consent = consent?; + if matches!(policy, ProfilePolicy::Aps(_)) { + // Preserve APS exactly: any admitted context produces regs and GDPR is + // derived only from the applicability bit, without jurisdiction rules. + let ext = RegsExt { + gdpr: Some(u8::from(consent.gdpr_applies)), + us_privacy: consent.raw_us_privacy.clone(), + gpp: consent.raw_gpp_string.clone(), + gpp_sid: consent.gpp_section_ids.clone(), + }; + return Some(Regs { + coppa: None, + gdpr: Some(consent.gdpr_applies), + us_privacy: ext.us_privacy.clone(), + gpp: ext.gpp.clone(), + gpp_sid: ext + .gpp_sid + .as_ref() + .map(|ids| ids.iter().copied().map(i32::from).collect()) + .unwrap_or_default(), + ext: ext.to_ext(), + }); + } + + // Standard deliberately shares PBS's conservative consent baseline. Keep + // the legacy PBS empty-context and jurisdiction behavior byte-for-byte. + let has_data = consent.gdpr_applies + || consent.raw_us_privacy.is_some() + || consent.raw_gpp_string.is_some() + || consent.gpp_section_ids.is_some() + || consent.gpc; + if !has_data { + return None; + } + let gdpr = if consent.gdpr_applies + || matches!( + consent.jurisdiction, + crate::consent::jurisdiction::Jurisdiction::Gdpr + ) { + Some(true) + } else if matches!( + consent.jurisdiction, + crate::consent::jurisdiction::Jurisdiction::Unknown + ) { + None + } else { + Some(false) + }; + let us_privacy = consent.raw_us_privacy.clone(); + let gpp = consent.raw_gpp_string.clone(); + let gpp_sid = consent.gpp_section_ids.clone(); + let ext = RegsExt { + gdpr: gdpr.map(u8::from), + us_privacy: us_privacy.clone(), + gpp: gpp.clone(), + gpp_sid: gpp_sid.clone(), + }; + Some(Regs { + coppa: None, + gdpr, + us_privacy, + gpp, + gpp_sid: gpp_sid + .map(|ids| ids.into_iter().map(i32::from).collect()) + .unwrap_or_default(), + ext: ext.to_ext(), + }) +} + +fn normalized_language( + headers: &PrebidTransportHeaders, + policy: ProfilePolicy<'_>, +) -> Option { + let value = header_string(headers.accept_language()) + .and_then(|value| value.split(',').next().map(str::to_string)) + .and_then(|value| value.split(';').next().map(str::to_string)) + .and_then(|value| value.split('-').next().map(str::to_string)) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + match policy { + ProfilePolicy::Prebid(_) => Some(value), + ProfilePolicy::Aps(_) | ProfilePolicy::Standard(_) => { + (value.len() <= MAX_CONSERVATIVE_LANGUAGE_BYTES).then_some(value) + } + } +} + +fn header_string(value: Option<&http::HeaderValue>) -> Option { + value + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +fn aps_inventory_page( + plan: &ApsProfilePlan, + publisher_page: Option<&str>, + domain: &str, +) -> Option { + let fallback = publisher_page + .and_then(valid_aps_page_url) + .unwrap_or_else(|| format!("https://{domain}")); + let Some(origin) = plan.inventory_page_origin.as_deref() else { + return Some(fallback); + }; + let (Ok(mut canonical), Ok(current)) = (Url::parse(origin), Url::parse(&fallback)) else { + return Some(fallback); + }; + canonical.set_path(current.path()); + canonical.set_query(current.query()); + canonical.set_fragment(None); + Some(canonical.to_string()) +} + +fn append_query_fragment(url: &str, query: &str) -> String { + if query.is_empty() || url.contains(query) { + return url.to_string(); + } + let separator = if url.contains('?') { '&' } else { '?' }; + format!("{url}{separator}{query}") +} + +fn valid_aps_page_url(value: &str) -> Option { + const MAX_APS_PAGE_URL_BYTES: usize = 8192; + + if value.len() > MAX_APS_PAGE_URL_BYTES { + return None; + } + let parsed = Url::parse(value).ok()?; + (matches!(parsed.scheme(), "http" | "https") + && parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none()) + .then(|| parsed.to_string()) +} + +fn nonempty_map(value: Map) -> Option> { + (!value.is_empty()).then_some(value) +} + +/// Suppress notification URLs using exact returned-seat identity. +pub(crate) fn apply_notification_policy(bids: &mut [Bid], policy: &NotificationPolicy) { + for bid in bids { + let suppress = policy.suppress_all + || bid + .returned_seat + .as_ref() + .is_some_and(|seat| policy.suppress_seats.contains(seat)); + if suppress { + bid.nurl = None; + bid.burl = None; + } + } +} + +/// Parse ordinary `OpenRTB` bids independently. Response ID is informational. +pub(crate) fn extract_standard_response( + provider_id: &str, + input: &ProviderAuctionInput, + value: &Value, + response_time_ms: u64, +) -> AuctionResponse { + let Some(response) = value.as_object() else { + return AuctionResponse::error(provider_id, response_time_ms); + }; + let allowed_impressions = input + .slots() + .iter() + .map(|slot| { + let dimensions = slot + .slot() + .formats + .iter() + .map(|format| (format.width, format.height)) + .collect::>(); + (slot.slot().id.as_str(), dimensions) + }) + .collect::>(); + let mut bids = Vec::new(); + for seatbid in response + .get("seatbid") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let returned_seat = seatbid + .get("seat") + .and_then(Value::as_str) + .filter(|seat| !seat.is_empty()); + let Some(entries) = seatbid.get("bid").and_then(Value::as_array) else { + continue; + }; + for value in entries { + if let Some(bid) = extract_standard_bid(value, returned_seat) + && allowed_impressions + .get(bid.slot_id.as_str()) + .is_some_and(|dimensions| dimensions.contains(&(bid.width, bid.height))) + { + bids.push(bid); + } + } + } + if bids.is_empty() { + AuctionResponse::no_bid(provider_id, response_time_ms) + } else { + AuctionResponse::success(provider_id, bids, response_time_ms) + } +} + +fn extract_standard_bid(value: &Value, returned_seat: Option<&str>) -> Option { + let slot_id = value.get("impid")?.as_str()?.to_string(); + let price = value + .get("price")? + .as_f64() + .filter(|price| price.is_finite() && *price >= 0.0)?; + let width = u32::try_from(value.get("w")?.as_u64()?) + .ok() + .filter(|value| *value > 0)?; + let height = u32::try_from(value.get("h")?.as_u64()?) + .ok() + .filter(|value| *value > 0)?; + let creative = value + .get("adm") + .and_then(Value::as_str) + .filter(|creative| !creative.is_empty()) + .map(str::to_string)?; + Some(Bid { + slot_id, + price: Some(price), + currency: DEFAULT_CURRENCY.to_string(), + creative: Some(creative), + adomain: value + .get("adomain") + .and_then(Value::as_array) + .map(|domains| { + domains + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }), + bidder: returned_seat.unwrap_or("unknown").to_string(), + returned_seat: returned_seat.map(str::to_string), + width, + height, + nurl: value + .get("nurl") + .and_then(Value::as_str) + .map(str::to_string), + burl: value + .get("burl") + .and_then(Value::as_str) + .map(str::to_string), + bid_id: value + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string), + ad_id: value + .get("adid") + .and_then(Value::as_str) + .map(str::to_string), + creative_id: value + .get("crid") + .and_then(Value::as_str) + .map(str::to_string), + renderer: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + }) +} + +/// Count bidder parameter objects a profile did not consume. +#[must_use] +pub(crate) fn unused_bidder_params_count( + profile: &CompiledOpenRtbProfile, + input: &ProviderAuctionInput, +) -> u32 { + if profile.is_prebid_server() { + return 0; + } + ignored_bidder_params_count(input) +} + +/// Count routed bidder params for a profile known to ignore them. +#[must_use] +pub(crate) fn ignored_bidder_params_count(input: &ProviderAuctionInput) -> u32 { + saturating_bidder_param_counts(input.slots().iter().map(|slot| slot.bidder_params().len())) +} + +fn saturating_bidder_param_counts(counts: impl IntoIterator) -> u32 { + counts.into_iter().fold(0_u32, |count, slot_count| { + count.saturating_add(u32::try_from(slot_count).unwrap_or(u32::MAX)) + }) +} + +#[cfg(test)] +mod routing_metadata_tests { + use std::collections::BTreeMap; + use std::str::FromStr as _; + + use serde_json::json; + + use super::{saturating_bidder_param_counts, unused_bidder_params_count}; + use crate::auction::plan::{ + AuctionPlan, AuctionPlanConfig, BidderId, BidderRouteConfig, NotificationConfig, + ProviderConfig, ProviderId, RoutingMode, + }; + use crate::auction::routing::route_auction; + use crate::auction::test_support::canonical_parity_auction_request; + + #[test] + fn unused_bidder_param_count_saturates_across_slots_and_large_values() { + assert_eq!(saturating_bidder_param_counts([1, 2, 3]), 6); + assert_eq!( + saturating_bidder_param_counts([usize::try_from(u32::MAX).unwrap_or(usize::MAX), 1]), + u32::MAX + ); + assert_eq!(saturating_bidder_param_counts([usize::MAX]), u32::MAX); + } + + #[test] + fn unused_bidder_param_count_is_profile_aware() { + for (profile, profile_config, expected) in [ + ("prebid-server", json!({}), 0), + ("standard", json!({}), 1), + ("aps", json!({"account_id":"example-account"}), 1), + ] { + let provider_id = + ProviderId::from_str("fictional-provider").expect("should parse provider ID"); + let plan = AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 1_000, + providers: BTreeMap::from([( + provider_id.clone(), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: profile.to_string(), + endpoint: if profile == "aps" { + "https://aps.example/e/pb/bid".to_string() + } else { + "https://provider.example/openrtb".to_string() + }, + timeout_ms: None, + routing: RoutingMode::Explicit, + notifications: NotificationConfig::default(), + profile_config, + }, + )]), + bidders: BTreeMap::from([( + BidderId::from_str("exampleBidder").expect("should parse bidder ID"), + BidderRouteConfig { + provider: provider_id, + }, + )]), + mediator: None, + request_signing: None, + }) + .expect("should compile profile plan"); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let routed = route_auction(canonical_parity_auction_request(), &inbound, &plan, None); + + assert_eq!( + unused_bidder_params_count(&plan.providers()[0].profile, &routed.inputs()[0]), + expected, + "{profile} should report only bidder params it ignores" + ); + } + } +} + +#[cfg(test)] +mod test_executor; +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-core/src/auction/openrtb/test_executor.rs b/crates/trusted-server-core/src/auction/openrtb/test_executor.rs new file mode 100644 index 000000000..a5e3263cf --- /dev/null +++ b/crates/trusted-server-core/src/auction/openrtb/test_executor.rs @@ -0,0 +1,106 @@ +//! Fictional standard-profile executor compiled only for automated tests. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt as _}; +use http::{Method, Request, StatusCode, header}; +use serde_json::{Value, json}; + +use super::{apply_notification_policy, extract_standard_response, unused_bidder_params_count}; +use crate::auction::plan::ProviderPlan; +use crate::auction::routing::ProviderAuctionInput; +use crate::auction::types::AuctionResponse; +use crate::error::TrustedServerError; +use crate::platform::{PlatformBackend, PlatformHttpClient, PlatformHttpRequest}; + +const MAX_STANDARD_RESPONSE_BYTES: usize = 1024 * 1024; + +/// Execute one fictional standard-profile request through a supplied test client. +/// +/// The HTTP client receives exactly one request. Redirect statuses are +/// classified as the original provider error and never followed. +pub(super) async fn execute_standard_fixture( + provider: &ProviderPlan, + input: &ProviderAuctionInput, + request: &trusted_server_openrtb::BidRequest, + backend: &dyn PlatformBackend, + http_client: &dyn PlatformHttpClient, +) -> Result> { + let spec = provider.backend_spec(); + let predicted_name = + backend + .predict_name(&spec) + .change_context(TrustedServerError::Auction { + message: "Failed to predict fictional standard backend".to_string(), + })?; + let backend_name = backend + .ensure(&spec) + .change_context(TrustedServerError::Auction { + message: "Failed to ensure fictional standard backend".to_string(), + })?; + if backend_name != predicted_name { + return Err(Report::new(TrustedServerError::Auction { + message: "Fictional standard backend ensure did not match prediction".to_string(), + })); + }; + let body = serde_json::to_vec(request).change_context(TrustedServerError::Auction { + message: "Failed to serialize fictional standard request".to_string(), + })?; + let outbound = Request::builder() + .method(Method::POST) + .uri(provider.endpoint.as_str()) + .header(header::CONTENT_TYPE, "application/json") + .header(header::ACCEPT, "application/json") + .body(EdgeBody::from(body)) + .change_context(TrustedServerError::Auction { + message: "Failed to build fictional standard request".to_string(), + })?; + let response = http_client + .send(PlatformHttpRequest::new(outbound, backend_name)) + .await + .change_context(TrustedServerError::Auction { + message: "Fictional standard transport failed".to_string(), + })? + .response; + let status = response.status(); + if status == StatusCode::NO_CONTENT { + return Ok( + AuctionResponse::no_bid(provider.id.as_str(), 0).with_metadata( + "routing", + json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + ), + ); + } + if !status.is_success() { + return Ok(AuctionResponse::error(provider.id.as_str(), 0) + .with_metadata("http_status", json!(status.as_u16())) + .with_metadata( + "routing", + json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + )); + } + let body = response + .into_body() + .into_bytes_bounded(MAX_STANDARD_RESPONSE_BYTES) + .await + .change_context(TrustedServerError::Auction { + message: "Fictional standard response exceeded its limit".to_string(), + })?; + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => { + return Ok( + AuctionResponse::error(provider.id.as_str(), 0).with_metadata( + "routing", + json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + ), + ); + } + }; + let mut parsed = extract_standard_response(provider.id.as_str(), input, &value, 0); + apply_notification_policy(&mut parsed.bids, &provider.notifications); + parsed.metadata.insert( + "routing".to_string(), + json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + ); + Ok(parsed) +} diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs new file mode 100644 index 000000000..8e5578704 --- /dev/null +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -0,0 +1,903 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::str::FromStr as _; +use std::sync::Arc; + +use base64::Engine as _; +use edgezero_core::body::Body as EdgeBody; +use http::{Request, header}; +use serde_json::{Value, json}; + +use super::test_executor::execute_standard_fixture; +use super::*; +use crate::auction::plan::{ + AuctionPlan, AuctionPlanConfig, BidderRouteConfig, NotificationConfig, ProviderConfig, + ProviderId, RoutingMode, +}; +use crate::auction::routing::route_auction; +use crate::auction::test_support::canonical_parity_auction_request; +use crate::auction::types::{AdFormat, AdSlot, BidStatus, MediaType}; +use crate::consent::jurisdiction::Jurisdiction; +use crate::consent::{ConsentContext, ConsentSource}; +use crate::platform::test_support::{ + HashMapConfigStore, HashMapSecretStore, NoopHttpClient, StubBackend, StubHttpClient, + build_services_with_config_secret_and_http_client, +}; +use crate::request_signing::RequestSigner; + +fn config(profile: &str, profile_config: Value) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 321, + providers: BTreeMap::from([( + ProviderId::from_str("fictional-provider").expect("should parse provider"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: profile.to_string(), + endpoint: "https://exchange.example.test/openrtb".to_string(), + timeout_ms: Some(321), + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config, + }, + )]), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, + } +} + +fn routed(profile: &str, profile_config: Value) -> (AuctionPlan, RoutedAuction) { + let plan = AuctionPlan::compile(config(profile, profile_config)).expect("should compile plan"); + let inbound = Request::builder() + .uri("https://publisher.example/auction") + .header( + header::REFERER, + "https://referrer.example/story?fictional=1", + ) + .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header("dnt", "1") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let routed = route_auction(canonical_parity_auction_request(), &inbound, &plan, None); + (plan, routed) +} + +fn routed_with_request( + profile: &str, + profile_config: Value, + request: crate::auction::types::AuctionRequest, + accept_language: Option<&str>, +) -> (AuctionPlan, RoutedAuction) { + let plan = AuctionPlan::compile(config(profile, profile_config)).expect("should compile plan"); + let mut builder = Request::builder().uri("https://publisher.example/auction"); + if let Some(language) = accept_language { + builder = builder.header(header::ACCEPT_LANGUAGE, language); + } + let inbound = builder + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let routed = route_auction(request, &inbound, &plan, None); + (plan, routed) +} + +fn build_with_request( + profile: &str, + profile_config: Value, + request: crate::auction::types::AuctionRequest, + accept_language: Option<&str>, +) -> OpenRtbRequest { + let (plan, routed) = routed_with_request(profile, profile_config, request, accept_language); + match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), + } +} + +fn finalization<'a>(signer: Option<&'a RequestSigner>) -> RequestFinalization<'a> { + RequestFinalization { + signer, + signing_params: SigningParams { + request_id: "fictional-auction".to_string(), + request_host: "publisher.example".to_string(), + request_scheme: "https".to_string(), + timestamp: 1_706_900_000, + }, + } +} + +fn build(profile: &str, profile_config: Value, signer: Option<&RequestSigner>) -> OpenRtbRequest { + let (plan, routed) = routed(profile, profile_config); + match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(signer), + ) + .expect("should build request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), + } +} + +fn deterministic_signer() -> RequestSigner { + let mut config_data = HashMap::new(); + config_data.insert("current-kid".to_string(), "fictional-kid".to_string()); + let mut secret_data = HashMap::new(); + secret_data.insert( + "fictional-kid".to_string(), + base64::engine::general_purpose::STANDARD + .encode([7_u8; 32]) + .into_bytes(), + ); + let services = build_services_with_config_secret_and_http_client( + HashMapConfigStore::new(config_data), + HashMapSecretStore::new(secret_data), + Arc::new(NoopHttpClient), + ); + RequestSigner::from_services(&services).expect("should load deterministic signer") +} + +#[test] +fn consent_matrix_preserves_pbs_standard_and_aps_policies() { + let cases = [ + ("empty", ConsentContext::default()), + ( + "gdpr", + ConsentContext { + gdpr_applies: true, + raw_tc_string: Some("tc-string".to_string()), + jurisdiction: Jurisdiction::Gdpr, + ..Default::default() + }, + ), + ( + "unknown-gpc", + ConsentContext { + gpc: true, + jurisdiction: Jurisdiction::Unknown, + ..Default::default() + }, + ), + ( + "nonregulated-gpc", + ConsentContext { + gpc: true, + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + ), + ( + "usp-gpp", + ConsentContext { + raw_us_privacy: Some("1YNN".to_string()), + raw_gpp_string: Some("gpp-string".to_string()), + gpp_section_ids: Some(vec![7, 8]), + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + ), + ]; + for (name, consent) in cases { + for profile in ["standard", "prebid-server", "aps"] { + let mut canonical = canonical_parity_auction_request(); + canonical.user.consent = Some(consent.clone()); + let config = if profile == "aps" { + json!({"account_id": "example-account-id"}) + } else { + json!({}) + }; + let value = serde_json::to_value(build_with_request(profile, config, canonical, None)) + .expect("should serialize request"); + let regs = value.get("regs"); + if profile == "aps" { + let regs = regs.expect("APS should preserve empty admitted context"); + assert_eq!( + regs["gdpr"], + json!(u8::from(consent.gdpr_applies)), + "{name}" + ); + } else if name == "empty" { + assert!(regs.is_none(), "{profile} should omit empty regs"); + } else { + let regs = regs.expect("should emit actionable regs"); + let expected_gdpr = match consent.jurisdiction { + Jurisdiction::Gdpr => Some(true), + Jurisdiction::Unknown if !consent.gdpr_applies => None, + _ => Some(consent.gdpr_applies), + }; + assert_eq!( + regs.get("gdpr"), + expected_gdpr.map(|value| json!(u8::from(value))).as_ref(), + "{name} {profile}" + ); + } + let serialized = value.to_string(); + assert!( + !serialized.contains("1YYY"), + "must never synthesize USP from GPC" + ); + if name == "usp-gpp" { + let regs = regs.expect("should have explicit fields"); + assert_eq!(regs["us_privacy"], "1YNN"); + assert_eq!(regs["gpp"], "gpp-string"); + assert_eq!(regs["gpp_sid"], json!([7, 8])); + assert_eq!(regs["ext"]["us_privacy"], "1YNN"); + assert_eq!(regs["ext"]["gpp"], "gpp-string"); + assert_eq!(regs["ext"]["gpp_sid"], json!([7, 8])); + } + } + } +} + +#[test] +fn pbs_body_consent_respects_source_and_forwarding_mode() { + for (mode, source, expected) in [ + ("cookies_only", ConsentSource::Cookie, false), + ("cookies_only", ConsentSource::KvStore, true), + ("cookies_only", ConsentSource::PolicyDefault, true), + ("openrtb_only", ConsentSource::Cookie, true), + ("both", ConsentSource::Cookie, true), + ] { + let mut canonical = canonical_parity_auction_request(); + canonical.user.consent.as_mut().expect("consent").source = source; + let value = serde_json::to_value(build_with_request( + "prebid-server", + json!({"consent_forwarding": mode}), + canonical, + None, + )) + .expect("should serialize request"); + assert_eq!( + value["user"].get("consent").is_some(), + expected, + "{mode:?} {source:?}" + ); + assert_eq!(value.get("regs").is_some(), expected, "{mode:?} {source:?}"); + } +} + +#[test] +fn language_limits_are_profile_specific() { + let language = "abcdefghijk"; + for (profile, expected) in [ + ("prebid-server", Some(language)), + ("aps", None), + ("standard", None), + ] { + let config = if profile == "aps" { + json!({"account_id": "example-account-id"}) + } else { + json!({}) + }; + let request = build_with_request( + profile, + config, + canonical_parity_auction_request(), + Some(language), + ); + assert_eq!( + request.device.and_then(|device| device.language).as_deref(), + expected + ); + } + for profile in ["prebid-server", "aps", "standard"] { + let config = if profile == "aps" { + json!({"account_id": "example-account-id"}) + } else { + json!({}) + }; + let request = build_with_request( + profile, + config, + canonical_parity_auction_request(), + Some("en-US,en;q=0.9"), + ); + assert_eq!( + request.device.and_then(|device| device.language).as_deref(), + Some("en") + ); + } +} + +#[test] +fn pbs_debug_query_fragment_preserves_exact_legacy_configured_semantics() { + for (page, fragment, expected) in [ + ( + "https://publisher.example/article", + "pbjs_debug=true", + "https://publisher.example/article?pbjs_debug=true", + ), + ( + "https://publisher.example/article?existing=1", + "pbjs_debug=true", + "https://publisher.example/article?existing=1&pbjs_debug=true", + ), + ( + "https://publisher.example/article", + "?pbjs_debug=true", + "https://publisher.example/article??pbjs_debug=true", + ), + ( + "https://publisher.example/article?pbjs_debug=true", + "pbjs_debug=true", + "https://publisher.example/article?pbjs_debug=true", + ), + ( + "https://publisher.example/article", + "", + "https://publisher.example/article", + ), + ] { + let mut request = canonical_parity_auction_request(); + request.publisher.page_url = Some(page.to_string()); + let built = build_with_request( + "prebid-server", + json!({"debug_query_params": fragment}), + request, + None, + ); + assert_eq!( + built.site.and_then(|site| site.page).as_deref(), + Some(expected), + "should preserve exact legacy query fragment semantics" + ); + } +} + +#[test] +fn pbs_routed_overrides_are_ordered_and_stored_request_is_trusted_fallback() { + let mut raw = config( + "prebid-server", + json!({ + "debug": true, + "test_mode": true, + "bid_param_overrides": {"exampleBidder": {"generic": 1, "shared": "generic"}}, + "bid_param_zone_overrides": {"exampleBidder": {"zone-a": {"zone": 2, "shared": "zone"}}}, + "bid_param_override_rules": [ + {"when":{"bidder":"exampleBidder"},"set":{"ordered":1,"shared":"rule-one"}}, + {"when":{"bidder":"exampleBidder","zone":"zone-a"},"set":{"ordered":2,"shared":"rule-two"}} + ] + }), + ); + raw.providers + .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) + .expect("should find provider") + .routing = RoutingMode::Explicit; + raw.bidders.insert( + crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), + }, + ); + let plan = AuctionPlan::compile(raw).expect("should compile PBS override plan"); + let mut request = canonical_parity_auction_request(); + request.slots[0].bidders = HashMap::from([( + "trustedServer".to_string(), + json!({"zone":"zone-a","bidderParams":{"exampleBidder":{"original":true,"shared":"original"}}}), + )]); + let inbound = Request::builder() + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let routed = route_auction(request, &inbound, &plan, None); + let built = match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), + }; + let value = serde_json::to_value(built).expect("should serialize request"); + assert_eq!( + value["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], + json!({"generic":1,"ordered":2,"original":true,"shared":"rule-two","zone":2}) + ); + assert_eq!(value["ext"]["prebid"]["debug"], true); + assert_eq!(value["ext"]["prebid"]["returnallbidstatus"], true); + assert_eq!(value["test"], 1); + + let mut stored = canonical_parity_auction_request(); + stored.slots[0].bidders.clear(); + let routed = route_auction(stored, &inbound, &plan, None); + let built = match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build stored request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), + }; + let value = serde_json::to_value(built).expect("should serialize stored request"); + assert_eq!( + value["imp"][0]["ext"]["prebid"]["storedrequest"]["id"], + "fictional-slot" + ); +} + +#[test] +fn pbs_driver_exact_golden_preserves_profile_policy() { + let mut raw = config("prebid-server", json!({"consent_forwarding": "both"})); + raw.providers + .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) + .expect("should find provider") + .routing = RoutingMode::Explicit; + raw.bidders.insert( + crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), + }, + ); + let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); + let mut common = canonical_parity_auction_request(); + common.slots[0].bidders = HashMap::from([( + "exampleBidder".to_string(), + json!({"placement": "fictional-placement"}), + )]); + let inbound = Request::builder() + .uri("https://publisher.example/auction") + .header( + header::REFERER, + "https://referrer.example/story?fictional=1", + ) + .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header("dnt", "1") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let routed = route_auction(common, &inbound, &plan, None); + let request = match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build PBS request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), + }; + assert_eq!( + serde_json::to_string(&request).expect("should serialize PBS driver request"), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"request_host":"publisher.example","request_scheme":"https"}}}"#, + "should preserve PBS parity differences" + ); +} + +#[test] +fn aps_inventory_identity_and_page_fallback_preserve_legacy_policy() { + let mut request = canonical_parity_auction_request(); + request.publisher.domain = "deployment.example".to_string(); + request.publisher.page_url = + Some("https://deployment.example/news/story?edition=fictional#section".to_string()); + let built = build_with_request( + "aps", + json!({ + "account_id": "example-account-id", + "inventory_domain": "publisher.example", + "inventory_page_origin": "https://www.publisher.example" + }), + request, + None, + ); + let site = built.site.expect("should include APS site"); + assert_eq!(site.domain.as_deref(), Some("publisher.example")); + assert_eq!( + site.page.as_deref(), + Some("https://www.publisher.example/news/story?edition=fictional") + ); + assert_eq!( + site.publisher + .and_then(|publisher| publisher.domain) + .as_deref(), + Some("publisher.example") + ); + + for unsafe_page in [ + "https://user:password@publisher.example/private", + "data:text/html,fictional", + ] { + let mut request = canonical_parity_auction_request(); + request.publisher.page_url = Some(unsafe_page.to_string()); + let built = build_with_request( + "aps", + json!({"account_id":"example-account-id"}), + request, + None, + ); + assert_eq!( + built.site.and_then(|site| site.page).as_deref(), + Some("https://publisher.example"), + "unsafe page should fall back to publisher domain" + ); + } +} + +#[test] +fn aps_driver_exact_golden_preserves_profile_policy() { + let request = build("aps", json!({"account_id": "example-account-id"}), None); + assert_eq!( + serde_json::to_string(&request).expect("should serialize APS driver request"), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}],"w":300,"h":250,"topframe":0},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"account":"example-account-id","sdk":{"source":"prebid","version":"2.2.0"}}}"#, + "should preserve APS parity differences" + ); +} + +#[test] +fn signing_finalization_is_after_profiles_and_asserts_every_owned_key() { + let signer = deterministic_signer(); + for (profile, config) in [ + ("standard", json!({"request_ext": {"fictional": true}})), + ("prebid-server", json!({})), + ("aps", json!({"account_id": "example-account-id"})), + ] { + let unsigned = serde_json::to_value(build(profile, config.clone(), None)) + .expect("should serialize unsigned request"); + let unsigned_ts = unsigned["ext"].get("trusted_server"); + if profile == "prebid-server" { + assert_eq!( + unsigned_ts, + Some(&json!({"request_host": "publisher.example", "request_scheme": "https"})), + "should retain only PBS host and scheme when unsigned" + ); + } else { + assert!( + unsigned_ts.is_none(), + "should omit unsigned non-PBS extension" + ); + } + + let signed = serde_json::to_value(build(profile, config, Some(&signer))) + .expect("should serialize signed request"); + let extension = &signed["ext"]["trusted_server"]; + assert_eq!(extension["version"], "1.1", "should set signing version"); + assert_eq!(extension["kid"], "fictional-kid", "should set key ID"); + assert_eq!( + extension["request_host"], "publisher.example", + "should set host" + ); + assert_eq!(extension["request_scheme"], "https", "should set scheme"); + assert_eq!( + extension["ts"], 1_706_900_000_u64, + "should set explicit time" + ); + assert!( + extension["signature"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "should set signature" + ); + } +} + +#[test] +fn signed_profiles_and_unsigned_standard_have_exact_full_goldens() { + let signer = deterministic_signer(); + let cases = [ + ( + "standard", + json!({"request_ext": {"fictional": true}}), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"fictional":true,"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, + ), + ( + "prebid-server", + json!({}), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, + ), + ( + "aps", + json!({"account_id": "example-account-id"}), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}],"w":300,"h":250,"topframe":0},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"account":"example-account-id","sdk":{"source":"prebid","version":"2.2.0"},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, + ), + ]; + for (profile, config, expected) in cases { + assert_eq!( + serde_json::to_string(&build(profile, config, Some(&signer))) + .expect("should serialize signed request"), + expected, + "{profile} signed wire fixture should stay exact" + ); + } + + assert_eq!( + serde_json::to_string(&build( + "standard", + json!({"request_ext": {"fictional": true}}), + None, + )) + .expect("should serialize unsigned standard request"), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"fictional":true}}"#, + "unsigned standard wire fixture should stay exact" + ); +} + +#[test] +fn standard_static_extensions_have_no_invented_bidder_param_location() { + let request = build( + "standard", + json!({ + "request_ext": {"fictional_request": {"enabled": true}}, + "imp_ext": {"fictional_imp": "value"} + }), + None, + ); + let value = serde_json::to_value(request).expect("should serialize request"); + assert_eq!(value["ext"]["fictional_request"]["enabled"], true); + assert_eq!(value["imp"][0]["ext"]["fictional_imp"], "value"); + assert!( + !value.to_string().contains("exampleBidder"), + "standard profile must not invent bidder params placement" + ); +} + +#[test] +fn defensive_no_impression_outcome_does_not_build_transportable_request() { + let (plan, mut routed) = routed("standard", json!({})); + let mut common = routed.inputs()[0].common_request().clone(); + common.slots = vec![AdSlot { + id: "video-only".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Video, + width: 640, + height: 480, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }]; + let inbound = Request::builder() + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + routed = route_auction(common, &inbound, &plan, None); + assert!( + routed.inputs().is_empty(), + "should omit provider input before build" + ); +} + +fn standard_fixture() -> (AuctionPlan, RoutedAuction, OpenRtbRequest) { + let mut raw = config( + "standard", + json!({"request_ext": {"fixture": true}, "imp_ext": {"slot_fixture": true}}), + ); + raw.providers + .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) + .expect("should find provider") + .routing = RoutingMode::Explicit; + raw.bidders.insert( + crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), + }, + ); + let plan = AuctionPlan::compile(raw).expect("should compile standard fixture plan"); + let inbound = Request::builder() + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let routed = route_auction(canonical_parity_auction_request(), &inbound, &plan, None); + let request = match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build standard fixture request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), + }; + (plan, routed, request) +} + +#[test] +fn standard_response_extraction_isolates_malformed_siblings_and_ignores_response_id() { + let (_plan, routed, _request) = standard_fixture(); + let response = extract_standard_response( + "fictional-provider", + &routed.inputs()[0], + &json!({ + "id": "informational-mismatch", + "seatbid": [{"seat": "fictional-seat", "bid": [ + {"id": "good", "impid": "fictional-slot", "price": 1.5, "adm": "
ok
", "w": 300, "h": 250}, + {"id": "bad", "impid": "fictional-slot", "price": "bad", "adm": "
bad
", "w": 300, "h": 250} + ]}] + }), + 9, + ); + assert_eq!(response.status, BidStatus::Success); + assert_eq!(response.bids.len(), 1, "should isolate malformed sibling"); + assert_eq!( + response.bids[0].returned_seat.as_deref(), + Some("fictional-seat") + ); +} + +#[test] +fn standard_response_rejects_unknown_impressions_and_dimensions_but_keeps_siblings() { + let (_plan, routed, _request) = standard_fixture(); + let response = extract_standard_response( + "fictional-provider", + &routed.inputs()[0], + &json!({"seatbid": [{"seat": "seat", "bid": [ + {"id":"good","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250}, + {"id":"unknown","impid":"unknown-slot","price":2.0,"adm":"bad","w":300,"h":250}, + {"id":"dimension","impid":"fictional-slot","price":3.0,"adm":"bad","w":320,"h":50} + ]}]}), + 0, + ); + assert_eq!(response.status, BidStatus::Success); + assert_eq!(response.bids.len(), 1); + assert_eq!(response.bids[0].bid_id.as_deref(), Some("good")); +} + +#[test] +fn notification_suppression_matrix_uses_only_exact_valid_returned_seat() { + let (_plan, routed, _request) = standard_fixture(); + let response = extract_standard_response( + "fictional-provider", + &routed.inputs()[0], + &json!({"seatbid": [ + {"seat": "exact", "bid": [{"id":"exact","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250,"nurl":"https://n.example","burl":"https://b.example"}]}, + {"seat": "Exact", "bid": [{"id":"case","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250,"nurl":"https://n.example","burl":"https://b.example"}]}, + {"bid": [{"id":"missing","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250,"nurl":"https://n.example","burl":"https://b.example"}]}, + {"seat": 7, "bid": [{"id":"nonstring","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250,"nurl":"https://n.example","burl":"https://b.example"}]} + ]}), + 0, + ); + let mut exact = response.bids.clone(); + apply_notification_policy( + &mut exact, + &NotificationPolicy { + suppress_all: false, + suppress_seats: BTreeSet::from(["exact".to_string(), "unknown".to_string()]), + }, + ); + assert!(exact[0].nurl.is_none(), "should suppress exact seat"); + assert!(exact[1].nurl.is_some(), "matching should be case-sensitive"); + assert!(exact[2].nurl.is_some(), "missing seat must not match"); + assert!(exact[3].nurl.is_some(), "non-string seat must not match"); + assert_eq!(exact[2].bidder, "unknown"); + assert_eq!(exact[3].bidder, "unknown"); + + let mut all = response.bids; + apply_notification_policy( + &mut all, + &NotificationPolicy { + suppress_all: true, + suppress_seats: BTreeSet::new(), + }, + ); + assert!( + all.iter() + .all(|bid| bid.nurl.is_none() && bid.burl.is_none()), + "suppress_all should remove every notification" + ); +} + +#[test] +fn fictional_standard_executor_covers_bid_no_bid_malformed_unused_and_redirect() { + futures::executor::block_on(async { + let (plan, routed, request) = standard_fixture(); + let provider = &plan.providers()[0]; + let client = Arc::new(StubHttpClient::new()); + client.push_response( + 200, + serde_json::to_vec(&json!({"id":"mismatch","seatbid":[{"seat":"fictional-seat","bid":[ + {"id":"good","impid":"fictional-slot","price":1.25,"adm":"
fictional
","w":300,"h":250}, + {"id":"bad","impid":"fictional-slot","price":null,"adm":"bad","w":300,"h":250} + ]}]})).expect("should serialize response"), + ); + let response = execute_standard_fixture( + provider, + &routed.inputs()[0], + &request, + &StubBackend, + client.as_ref(), + ) + .await + .expect("should execute ordinary fixture"); + assert_eq!(response.status, BidStatus::Success); + assert_eq!(response.bids.len(), 1, "should isolate malformed sibling"); + assert_eq!( + response.metadata["routing"]["unused_bidder_params_count"], + 1 + ); + assert_eq!( + client.recorded_request_uris(), + vec![provider.endpoint.as_str()] + ); + let headers = &client.recorded_request_headers()[0]; + assert!( + !headers.iter().any(|(name, _)| name == "authorization"), + "fixture must add no authentication" + ); + let body: Value = serde_json::from_slice(&client.recorded_request_bodies()[0]) + .expect("should parse recorded body"); + assert_eq!(body["ext"]["fixture"], true, "should send static extension"); + + let no_bid = Arc::new(StubHttpClient::new()); + no_bid.push_response(204, Vec::new()); + let response = execute_standard_fixture( + provider, + &routed.inputs()[0], + &request, + &StubBackend, + no_bid.as_ref(), + ) + .await + .expect("should execute no-bid fixture"); + assert_eq!(response.status, BidStatus::NoBid); + + let malformed = Arc::new(StubHttpClient::new()); + malformed.push_response(200, b"not-json".to_vec()); + let response = execute_standard_fixture( + provider, + &routed.inputs()[0], + &request, + &StubBackend, + malformed.as_ref(), + ) + .await + .expect("should classify malformed response"); + assert_eq!(response.status, BidStatus::Error); + + let redirect = Arc::new(StubHttpClient::new()); + redirect.push_response_with_headers( + 302, + Vec::new(), + vec![("location", "https://redirect.example.test/openrtb")], + ); + let response = execute_standard_fixture( + provider, + &routed.inputs()[0], + &request, + &StubBackend, + redirect.as_ref(), + ) + .await + .expect("should classify redirect"); + assert_eq!(response.status, BidStatus::Error); + assert_eq!(response.metadata["http_status"], 302); + assert_eq!( + redirect.recorded_request_uris(), + vec![provider.endpoint.as_str()], + "a 3xx Location must not trigger a second HTTP request" + ); + assert_eq!( + redirect.recorded_backend_names(), + vec!["stub-backend"], + "the common driver must perform exactly one underlying send for a 3xx" + ); + let spec = provider.backend_spec(); + assert_eq!(spec.host, "exchange.example.test"); + assert_eq!(spec.discriminator.as_deref(), Some("fictional-provider")); + }); +} + +#[test] +fn malformed_top_level_standard_response_is_error() { + let (_plan, routed, _request) = standard_fixture(); + let response = + extract_standard_response("fictional-provider", &routed.inputs()[0], &json!([]), 0); + assert_eq!(response.status, BidStatus::Error); +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index ae8dc9821..572754d3b 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -3,18 +3,26 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::Request; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, hash_map::Entry}; use std::sync::Arc; -use std::time::Duration; use web_time::Instant; use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, RuntimeServices}; +#[cfg(test)] use super::config::AuctionConfig; -use super::provider::{AuctionProvider, ProviderParseState, ProviderRequestOutcome}; +use super::openrtb::unused_bidder_params_count; +use super::plan::AuctionPlan; +use super::provider::{ + AuctionProvider, GenericOpenRtbProvider, ProviderParseState, ProviderRequestOutcome, +}; +#[cfg(test)] +use super::routing::RoutedAuction; +use super::routing::route_auction; use super::telemetry::AbandonedProviderCall; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +use crate::request_signing::RequestSigner; /// In-flight auction requests dispatched to SSP backends. /// @@ -26,6 +34,7 @@ use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStat pub struct DispatchedAuction { pending_requests: Vec, backend_to_provider: HashMap, + planned_backend_to_provider: HashMap, completed_responses: Vec, auction_start: Instant, timeout_ms: u32, @@ -33,6 +42,9 @@ pub struct DispatchedAuction { provider_request_context: Box>, /// Carried so the mediator call in collect can pass it as the auction request. request: AuctionRequest, + planned_unused_bidder_params: HashMap, + planned_unroutable_bidder_count: u32, + planned_provider_order: HashMap, } struct ProviderLaunchState { @@ -44,6 +56,7 @@ struct ProviderLaunchState { } /// Outcome of attempting to dispatch split-phase auction provider requests. +#[allow(clippy::large_enum_variant)] pub enum DispatchAuctionOutcome { /// No provider request was started and no provider failure was observed. NotStarted, @@ -53,6 +66,13 @@ pub enum DispatchAuctionOutcome { request: AuctionRequest, /// Provider launch-failure responses. provider_responses: Vec, + /// Fatal admission error that synchronous execution must propagate. + /// + /// Split publisher dispatch records the failure and continues without + /// attempting provider network I/O. + fatal_admission_error: Option>, + /// Auction-level metadata materialized before the failure. + metadata: HashMap, /// Elapsed dispatch time. elapsed_ms: u64, }, @@ -75,10 +95,16 @@ impl DispatchedAuction { let abandoned = self .backend_to_provider .into_values() - .map(|state| { + .map(|state| (state.provider_name, state.started_at)) + .chain( + self.planned_backend_to_provider + .into_values() + .map(|state| (state.provider.provider_name().to_string(), state.started_at)), + ) + .map(|(provider_name, started_at)| { AbandonedProviderCall::bidder( - state.provider_name, - Some(u32::try_from(state.started_at.elapsed().as_millis()).unwrap_or(u32::MAX)), + provider_name, + Some(u32::try_from(started_at.elapsed().as_millis()).unwrap_or(u32::MAX)), ) }) .collect(); @@ -97,12 +123,16 @@ impl DispatchedAuction { Self { pending_requests: Vec::new(), backend_to_provider: HashMap::new(), + planned_backend_to_provider: HashMap::new(), completed_responses: Vec::new(), auction_start: Instant::now(), timeout_ms, floor_prices: HashMap::new(), provider_request_context: Box::new(Request::new(EdgeBody::empty())), request, + planned_unused_bidder_params: HashMap::new(), + planned_unroutable_bidder_count: 0, + planned_provider_order: HashMap::new(), } } } @@ -169,6 +199,13 @@ fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> Auct .with_metadata("message", serde_json::json!("Provider request timed out")) } +fn provider_skipped_response(provider_name: &str) -> AuctionResponse { + AuctionResponse::no_bid(provider_name, 0).with_metadata( + "routing", + serde_json::json!({"skipped_no_eligible_slots": true}), + ) +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -179,6 +216,68 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } +/// Runtime policy for classifying responses that complete after the logical auction budget. +/// +/// Current adapters do not expose an enforceable total-request deadline. They +/// therefore drain already-launched work and accept completed late responses. +#[derive(Debug, Clone, Copy, Default)] +struct AuctionDeadlinePolicy { + enforceable_total_request_deadline: bool, +} + +impl AuctionDeadlinePolicy { + fn rejects_late_completion(self, start: Instant, timeout_ms: u32) -> bool { + self.enforceable_total_request_deadline && remaining_budget_ms(start, timeout_ms) == 0 + } + + fn for_runtime(services: &RuntimeServices) -> Self { + Self { + enforceable_total_request_deadline: services + .http_client() + .has_enforceable_total_request_deadline(), + } + } +} + +fn routing_metadata(unroutable_bidder_count: u32) -> HashMap { + HashMap::from([( + "routing".to_string(), + serde_json::json!({"unroutable_bidder_count": unroutable_bidder_count}), + )]) +} + +/// Attach only the count derived from the routed provider input at dispatch. +/// +/// This is intentionally applied after every provider outcome is materialized, +/// including failures produced before or during parsing. Skipped providers are +/// routed separately and retain their exclusive skipped diagnostic. +fn materialize_planned_response( + mut response: AuctionResponse, + unused_bidder_params_count: u32, +) -> AuctionResponse { + let routing = response + .metadata + .entry("routing".to_string()) + .or_insert_with(|| serde_json::json!({})); + if routing + .get("skipped_no_eligible_slots") + .is_some_and(|value| value == &serde_json::json!(true)) + { + return response; + } + if !routing.is_object() { + *routing = serde_json::json!({}); + } + routing + .as_object_mut() + .expect("should normalize planned routing metadata to an object") + .insert( + "unused_bidder_params_count".to_string(), + serde_json::json!(unused_bidder_params_count), + ); + response +} + fn snapshot_context_request(request: &Request) -> Request { let mut snapshot = Request::new(EdgeBody::empty()); *snapshot.method_mut() = request.method().clone(); @@ -190,85 +289,520 @@ fn snapshot_context_request(request: &Request) -> Request { /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { + enabled: bool, + plan_backed: bool, + plan: Arc, + planned_providers: Vec>, + mediator: Option>, + #[cfg(test)] config: AuctionConfig, + #[cfg(test)] providers: HashMap>, } -impl AuctionOrchestrator { - /// Create a new orchestrator with the given configuration. - #[must_use] - pub fn new(config: AuctionConfig) -> Self { +/// Test harness for the live plan-backed orchestrator semantics. +#[cfg(test)] +pub(crate) struct AuctionOrchestratorHarness { + plan: Arc, + providers: Vec>, + mediator: Option>, +} + +struct PlannedLaunchState { + provider: Arc, + started_at: Instant, + parse_state: Option, +} + +#[cfg(test)] +#[allow( + dead_code, + reason = "test harness exercises plan-backed runtime behavior" +)] +impl AuctionOrchestratorHarness { + pub(crate) fn new( + plan: impl Into>, + mediator: Option>, + ) -> Self { + let plan = plan.into(); + let providers = plan + .providers() + .iter() + .cloned() + .map(GenericOpenRtbProvider::new) + .map(Arc::new) + .collect(); Self { - config, - providers: HashMap::new(), + plan, + providers, + mediator, } } - /// Register an auction provider. - pub fn register_provider(&mut self, provider: Arc) { - let name = provider.provider_name().to_string(); - log::info!("Registering auction provider: {}", name); - self.providers.insert(name, provider); + pub(crate) fn provider_count(&self) -> usize { + self.providers.len() } - /// Get the number of registered providers. - #[must_use] - pub fn provider_count(&self) -> usize { - self.providers.len() + pub(crate) fn mediator(&self) -> Option<&Arc> { + self.mediator.as_ref() } - /// Validate that every configured provider name has an enabled provider integration. - pub(crate) fn validate_configured_provider_names( + /// Route and execute config-first bidder providers in deterministic order. + pub(crate) async fn run_auction( &self, - ) -> Result<(), Report> { - if !self.config.enabled { - return Ok(()); - } - - let mut configured_providers = HashSet::new(); - for provider_name in &self.config.providers { - if !configured_providers.insert(provider_name.as_str()) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Auction provider `{provider_name}` is listed more than once in [auction].providers; each provider may appear at most once" - ), - })); - } + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + // Admission, including signer-store reads and routing, consumes the same + // request-local deadline as provider transport and response collection. + let auction_start = Instant::now(); + let routed = route_auction( + request.clone(), + context.request, + &self.plan, + context.services.client_info().client_ip, + ); + if context.timeout_ms == 0 { + return self + .run_routed(request, &routed, context, None, auction_start) + .await; } - - if let Some(mediator_name) = &self.config.mediator - && configured_providers.contains(mediator_name.as_str()) + if self.providers.len() > 1 && !context.services.http_client().supports_concurrent_fanout() { - return Err(Report::new(TrustedServerError::Configuration { + return Err(Report::new(TrustedServerError::Auction { message: format!( - "Auction mediator `{mediator_name}` is also listed in [auction].providers; a provider may not mediate its own auction" + "{} auction providers configured, but this platform's HTTP client does not support concurrent fanout", + self.providers.len() ), })); } - for provider_name in self - .config - .providers + // Signing admission deliberately precedes every backend call. + let signer = self + .plan + .signing_enabled() + .then(|| RequestSigner::from_services(context.services)) + .transpose()?; + self.run_routed(request, &routed, context, signer.as_ref(), auction_start) + .await + } + + async fn run_routed( + &self, + original_request: &AuctionRequest, + routed: &RoutedAuction, + context: &AuctionContext<'_>, + signer: Option<&RequestSigner>, + auction_start: Instant, + ) -> Result> { + let mut responses = routed + .skipped_no_eligible_provider_ids() .iter() - .chain(self.config.mediator.iter()) - { - if !self.providers.contains_key(provider_name) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Auction provider `{provider_name}` is listed in [auction] but no enabled integration provides it" + .map(|id| provider_skipped_response(id.as_str())) + .collect::>(); + let planned_unused_bidder_params = routed + .inputs() + .iter() + .map(|input| { + ( + input.provider_id().as_str().to_string(), + unused_bidder_params_count( + &self + .plan + .provider(input.provider_id()) + .expect("should find routed provider in compiled plan") + .profile, + input, ), - })); + ) + }) + .collect::>(); + let mut pending = Vec::new(); + let mut launches = HashMap::new(); + let mut reserved_backend_names = HashSet::new(); + + for input in routed.inputs() { + let Some(provider) = self + .providers + .iter() + .find(|provider| provider.provider_name() == input.provider_id().as_str()) + .cloned() + else { + responses.push(provider_launch_failed_response( + input.provider_id().as_str(), + 0, + )); + continue; + }; + let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); + let logical_budget_ms = remaining_ms.min(provider.timeout_ms()); + if logical_budget_ms == 0 { + responses.push(provider_timeout_response(provider.provider_name(), 0)); + continue; + } + let transport_timeout_ms = context + .services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, provider.timeout_ms()); + let started_at = Instant::now(); + match provider + .request_bids_routed( + input, + routed, + logical_budget_ms, + transport_timeout_ms, + signer, + context.services, + &mut reserved_backend_names, + ) + .await + { + Ok(ProviderRequestOutcome::Pending { + request: launched, + parse_state, + }) => { + let Some(backend_name) = launched.backend_name().map(str::to_string) else { + log::warn!( + "Planned provider '{}' pending request had no backend name", + provider.provider_name() + ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + continue; + }; + match launches.entry(backend_name) { + Entry::Vacant(entry) => { + entry.insert(PlannedLaunchState { + provider, + started_at, + parse_state, + }); + pending.push(launched); + } + Entry::Occupied(entry) => { + log::warn!( + "Planned provider '{}' pending backend '{}' already belongs to another provider", + provider.provider_name(), + entry.key(), + ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + } + } + } + Ok(ProviderRequestOutcome::Immediate(response)) => responses.push(response), + Err(error) => { + log::warn!( + "Planned provider '{}' failed to launch: {:?}", + provider.provider_name(), + error + ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + } + } + } + + while !pending.is_empty() { + let select_result = match context.services.http_client().select(pending).await { + Ok(result) => result, + Err(error) => { + log::warn!("Planned provider select failed: {:?}", error); + break; + } + }; + pending = select_result.remaining; + match select_result.ready { + Ok(platform_response) => { + let backend_name = platform_response + .backend_name + .as_deref() + .unwrap_or_default() + .to_string(); + if let Some(state) = launches.remove(&backend_name) { + let elapsed_ms = state.started_at.elapsed().as_millis() as u64; + let deadline_policy = AuctionDeadlinePolicy::for_runtime(context.services); + if deadline_policy + .rejects_late_completion(auction_start, context.timeout_ms) + { + responses.push(provider_timeout_response( + state.provider.provider_name(), + elapsed_ms, + )); + continue; + } + match state + .provider + .parse_response_with_state( + platform_response, + elapsed_ms, + state.parse_state.as_deref(), + ) + .await + { + Ok(response) => responses.push(response), + Err(error) => responses.push(provider_error_response( + state.provider.provider_name(), + elapsed_ms, + ERROR_TYPE_PARSE_RESPONSE, + &error, + )), + } + } + } + Err(error) => { + if let Some(backend_name) = select_result.failed_backend_name + && let Some(state) = launches.remove(&backend_name) + { + let elapsed_ms = state.started_at.elapsed().as_millis() as u64; + log::warn!( + "Planned provider '{}' transport failed: {:?}", + state.provider.provider_name(), + error + ); + responses.push(provider_transport_failed_response( + state.provider.provider_name(), + elapsed_ms, + )); + } + } + } + } + for state in launches.into_values() { + responses.push(provider_timeout_response( + state.provider.provider_name(), + state.started_at.elapsed().as_millis() as u64, + )); + } + + for response in &mut responses { + if let Some(&unused_bidder_params_count) = + planned_unused_bidder_params.get(response.provider.as_str()) + { + *response = + materialize_planned_response(response.clone(), unused_bidder_params_count); + } + } + + let provider_order = self + .plan + .providers() + .iter() + .enumerate() + .map(|(index, provider)| (provider.id.as_str(), index)) + .collect::>(); + responses.sort_by_key(|response| { + provider_order + .get(response.provider.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + + let floor_prices = original_request + .slots + .iter() + .filter_map(|slot| slot.floor_price.map(|floor| (slot.id.clone(), floor))) + .collect::>(); + let helper = AuctionOrchestrator::new(AuctionConfig::default()); + let local_winners = || helper.select_winning_bids(&responses, &floor_prices); + let (mediator_response, winning_bids) = if let Some(mediator) = &self.mediator { + let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); + let logical_budget_ms = remaining_ms.min(mediator.timeout_ms()); + if logical_budget_ms == 0 { + log::warn!( + "Auction deadline exhausted before planned mediator; using local ranking" + ); + (None, local_winners()) + } else { + let transport_timeout_ms = context + .services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); + let mediator_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: logical_budget_ms, + transport_timeout_ms, + provider_responses: Some(&responses), + services: context.services, + }; + let mediator_start = Instant::now(); + let mediated = match mediator + .request_bids(original_request, &mediator_context) + .await + { + Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => match context.services.http_client().wait(pending).await { + Ok(platform_response) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + if AuctionDeadlinePolicy::for_runtime(context.services) + .rejects_late_completion(auction_start, context.timeout_ms) + { + log::warn!( + "Planned mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", + mediator.provider_name(), + response_time_ms + ); + None + } else { + mediator + .parse_response_with_context_and_state( + platform_response, + response_time_ms, + original_request, + &mediator_context, + parse_state.as_deref(), + ) + .await + .map_err(|error| { + log::warn!( + "Planned mediator '{}' parse failed: {:?}", + mediator.provider_name(), + error + ); + }) + .ok() + } + } + Err(error) => { + log::warn!( + "Planned mediator '{}' request failed: {:?}", + mediator.provider_name(), + error + ); + None + } + }, + Err(error) => { + log::warn!( + "Planned mediator '{}' failed to launch: {:?}", + mediator.provider_name(), + error + ); + None + } + }; + if let Some(mediated) = mediated { + let winners = mediated + .bids + .iter() + .filter_map(|bid| { + if bid.price.is_none() { + log::warn!( + "Planned mediator returned a bid without a decoded price" + ); + None + } else { + Some((bid.slot_id.clone(), bid.clone())) + } + }) + .collect(); + ( + Some(mediated), + helper.apply_floor_prices(winners, &floor_prices), + ) + } else { + (None, local_winners()) + } } + } else { + (None, local_winners()) + }; + let unroutable_bidder_count = routed.diagnostics().unroutable_bidder_count(); + log::info!( + "Auction routing diagnostics: unroutable_bidder_count={}", + unroutable_bidder_count + ); + Ok(OrchestrationResult { + provider_responses: responses, + mediator_response, + winning_bids, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: routing_metadata(unroutable_bidder_count), + }) + } +} + +impl AuctionOrchestrator { + /// Create a legacy orchestrator for parity tests. + #[cfg(test)] + #[must_use] + pub(crate) fn new(config: AuctionConfig) -> Self { + let plan = Arc::new( + AuctionPlan::compile(super::plan::AuctionPlanConfig { + timeout_ms: config.timeout_ms, + providers: std::collections::BTreeMap::new(), + bidders: std::collections::BTreeMap::new(), + mediator: None, + request_signing: None, + }) + .expect("should compile empty legacy test plan") + .with_enabled(config.enabled), + ); + Self { + enabled: config.enabled, + plan_backed: false, + config, + plan, + planned_providers: Vec::new(), + mediator: None, + providers: HashMap::new(), + } + } + + /// Create the live orchestrator from one shared compiled auction plan. + #[must_use] + pub fn from_plan(plan: Arc, mediator: Option>) -> Self { + let planned_providers = plan + .providers() + .iter() + .cloned() + .map(GenericOpenRtbProvider::new) + .map(Arc::new) + .collect(); + Self { + enabled: plan.enabled(), + plan_backed: true, + plan, + planned_providers, + mediator, + #[cfg(test)] + config: AuctionConfig::default(), + #[cfg(test)] + providers: HashMap::new(), } + } - Ok(()) + /// Return whether this orchestrator and another plan consumer share the same plan allocation. + #[must_use] + pub fn shares_plan(&self, plan: &Arc) -> bool { + Arc::ptr_eq(&self.plan, plan) } - /// Execute an auction using the auto-detected strategy. - /// - /// Strategy is determined by mediator configuration: - /// - If mediator is configured: runs parallel mediation (bidders → mediator decides) - /// - If no mediator: runs parallel only (bidders → highest CPM wins) + /// Register an auction provider in the legacy parity harness. + #[cfg(test)] + pub(crate) fn register_provider(&mut self, provider: Arc) { + let name = provider.provider_name().to_string(); + log::info!("Registering auction provider: {}", name); + self.providers.insert(name, provider); + } + + /// Get the number of registered providers. + #[must_use] + pub fn provider_count(&self) -> usize { + self.planned_providers.len() + } + + /// Execute an auction through the compiled plan. /// /// # Errors /// @@ -279,9 +813,88 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + if !self.enabled { + return Ok(OrchestrationResult::no_bid()); + } + #[cfg(not(test))] + { + return match self.dispatch_auction(request, context).await { + DispatchAuctionOutcome::Dispatched(dispatched) => Ok(self + .collect_dispatched_auction(dispatched, context.services, context) + .await), + DispatchAuctionOutcome::DispatchFailed { + provider_responses, + fatal_admission_error, + metadata, + elapsed_ms, + .. + } => { + if let Some(error) = fatal_admission_error { + return Err(error.change_context(TrustedServerError::Auction { + message: "Planned auction admission failed".to_string(), + })); + } + Ok(OrchestrationResult { + provider_responses, + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: elapsed_ms, + metadata, + }) + } + DispatchAuctionOutcome::NotStarted => { + if self.planned_providers.is_empty() { + Ok(OrchestrationResult::no_bid()) + } else { + Err(Report::new(TrustedServerError::Auction { + message: "No planned provider request was started".to_string(), + })) + } + } + }; + } + #[cfg(test)] + if self.plan_backed { + return match self.dispatch_auction(request, context).await { + DispatchAuctionOutcome::Dispatched(dispatched) => Ok(self + .collect_dispatched_auction(dispatched, context.services, context) + .await), + DispatchAuctionOutcome::DispatchFailed { + provider_responses, + fatal_admission_error, + metadata, + elapsed_ms, + .. + } => { + if let Some(error) = fatal_admission_error { + return Err(error.change_context(TrustedServerError::Auction { + message: "Planned auction admission failed".to_string(), + })); + } + Ok(OrchestrationResult { + provider_responses, + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: elapsed_ms, + metadata, + }) + } + DispatchAuctionOutcome::NotStarted => { + if self.planned_providers.is_empty() { + Ok(OrchestrationResult::no_bid()) + } else { + Err(Report::new(TrustedServerError::Auction { + message: "No planned provider request was started".to_string(), + })) + } + } + }; + } + #[cfg(test)] let start_time = Instant::now(); - // Auto-detect strategy based on mediator configuration + // Auto-detect strategy based on mediator configuration. + #[cfg(test)] let (strategy_name, result) = if self.config.has_mediator() { ( "parallel_mediation", @@ -294,11 +907,13 @@ impl AuctionOrchestrator { ) }; + #[cfg(test)] log::info!( "Running auction with strategy: {} (auto-detected from mediator config)", strategy_name ); + #[cfg(test)] Ok(OrchestrationResult { total_time_ms: start_time.elapsed().as_millis() as u64, ..result @@ -306,6 +921,7 @@ impl AuctionOrchestrator { } /// Run auction with parallel bidding + mediation. + #[cfg(test)] /// /// Flow: /// 1. Run all bidders in parallel @@ -355,6 +971,7 @@ impl AuctionOrchestrator { settings: context.settings, request: context.request, timeout_ms: mediator_timeout, + transport_timeout_ms: mediator_timeout, provider_responses: Some(&provider_responses), services: context.services, }; @@ -382,11 +999,29 @@ impl AuctionOrchestrator { mediator.provider_name() ), })?; - - mediator + let response_time_ms = start_time.elapsed().as_millis() as u64; + if AuctionDeadlinePolicy::for_runtime(context.services) + .rejects_late_completion(mediation_start, context.timeout_ms) + { + log::warn!( + "Mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", + mediator.provider_name(), + response_time_ms + ); + let winning = self.select_winning_bids(&provider_responses, &floor_prices); + return Ok(OrchestrationResult { + provider_responses, + mediator_response: None, + winning_bids: winning, + total_time_ms: 0, + metadata: HashMap::new(), + }); + } + + mediator .parse_response_with_context_and_state( platform_resp, - start_time.elapsed().as_millis() as u64, + response_time_ms, request, &mediator_context, parse_state.as_deref(), @@ -436,6 +1071,7 @@ impl AuctionOrchestrator { } /// Run auction with only parallel bidding (no mediation). + #[cfg(test)] async fn run_parallel_only( &self, request: &AuctionRequest, @@ -455,6 +1091,7 @@ impl AuctionOrchestrator { } /// Run all providers in parallel and collect responses. + #[cfg(test)] /// /// Uses `PlatformHttpClient::select()` to process responses as they /// become ready, rather than waiting for each response sequentially. @@ -463,7 +1100,12 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result, Report> { - let provider_names = self.config.provider_names(); + let provider_names = self + .config + .providers + .keys() + .map(super::plan::ProviderId::as_str) + .collect::>(); if provider_names.is_empty() { return Err(Report::new(TrustedServerError::Auction { @@ -502,8 +1144,8 @@ impl AuctionOrchestrator { let mut responses = Vec::new(); let mut immediate_response_count = 0usize; - for provider_name in provider_names { - let provider = match self.providers.get(provider_name) { + for provider_name in &provider_names { + let provider = match self.providers.get(*provider_name) { Some(p) => p, None => { log::warn!("Provider '{}' not registered, skipping", provider_name); @@ -555,6 +1197,7 @@ impl AuctionOrchestrator { settings: context.settings, request: context.request, timeout_ms: effective_timeout, + transport_timeout_ms: effective_timeout, provider_responses: context.provider_responses, services: context.services, }; @@ -658,7 +1301,7 @@ impl AuctionOrchestrator { })); } - let deadline = Duration::from_millis(u64::from(context.timeout_ms)); + let deadline_policy = AuctionDeadlinePolicy::for_runtime(context.services); log::info!( "Launched {} concurrent provider request(s); waiting for responses", pending_requests.len() @@ -702,10 +1345,20 @@ impl AuctionOrchestrator { if let Some(state) = backend_to_provider.remove(&backend_name) { let response_time_ms = state.started_at.elapsed().as_millis() as u64; + if deadline_policy + .rejects_late_completion(auction_start, context.timeout_ms) + { + responses.push(provider_timeout_response( + &state.provider_name, + response_time_ms, + )); + continue; + } let provider_context = AuctionContext { settings: context.settings, request: context.request, timeout_ms: state.effective_timeout_ms, + transport_timeout_ms: state.effective_timeout_ms, provider_responses: context.provider_responses, services: context.services, }; @@ -783,16 +1436,10 @@ impl AuctionOrchestrator { } } - // Check auction deadline after processing each response. - // Remaining PendingRequests are dropped, which abandons the - // in-flight HTTP calls on the Fastly host. - if auction_start.elapsed() >= deadline && !remaining.is_empty() { - log::warn!( - "Auction timeout reached; dropping {} remaining request(s)", - remaining.len() - ); - break; - } + // Current adapters cannot enforce a hard total-request deadline, so + // drain already-launched handles and retain completed late responses. + // A future adapter that explicitly claims the capability classifies + // each late completion as a timeout instead. } for state in backend_to_provider.into_values() { @@ -907,6 +1554,7 @@ impl AuctionOrchestrator { } /// Get a provider by name. + #[cfg(test)] fn get_provider( &self, name: &str, @@ -923,6 +1571,215 @@ impl AuctionOrchestrator { }) } + async fn dispatch_planned_auction( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> DispatchAuctionOutcome { + let plan = &self.plan; + if self.planned_providers.is_empty() { + return DispatchAuctionOutcome::NotStarted; + } + if self.planned_providers.len() > 1 + && !context.services.http_client().supports_concurrent_fanout() + { + log::warn!( + "{} planned auction providers configured on a runtime without concurrent fanout", + self.planned_providers.len() + ); + return DispatchAuctionOutcome::NotStarted; + } + + let auction_start = Instant::now(); + let routed = route_auction( + request.clone(), + context.request, + plan, + context.services.client_info().client_ip, + ); + let planned_unused_bidder_params = routed + .inputs() + .iter() + .map(|input| { + ( + input.provider_id().as_str().to_string(), + unused_bidder_params_count( + &plan + .provider(input.provider_id()) + .expect("should find routed provider in compiled plan") + .profile, + input, + ), + ) + }) + .collect::>(); + let planned_unroutable_bidder_count = routed.diagnostics().unroutable_bidder_count(); + let signer = match plan + .signing_enabled() + .then(|| RequestSigner::from_services(context.services)) + .transpose() + { + Ok(signer) => signer, + Err(error) => { + log::warn!("Planned auction signer initialization failed: {error:?}"); + let mut provider_responses = routed + .skipped_no_eligible_provider_ids() + .iter() + .map(|id| provider_skipped_response(id.as_str())) + .chain(routed.inputs().iter().map(|input| { + materialize_planned_response( + provider_launch_failed_response(input.provider_id().as_str(), 0), + unused_bidder_params_count( + &plan + .provider(input.provider_id()) + .expect("should find routed provider in compiled plan") + .profile, + input, + ), + ) + })) + .collect::>(); + let provider_order = plan + .providers() + .iter() + .enumerate() + .map(|(index, provider)| (provider.id.as_str(), index)) + .collect::>(); + provider_responses.sort_by_key(|response| { + provider_order + .get(response.provider.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + return DispatchAuctionOutcome::DispatchFailed { + request: request.clone(), + provider_responses, + fatal_admission_error: Some(error), + metadata: routing_metadata(planned_unroutable_bidder_count), + elapsed_ms: auction_start.elapsed().as_millis() as u64, + }; + } + }; + let mut completed_responses = routed + .skipped_no_eligible_provider_ids() + .iter() + .map(|id| provider_skipped_response(id.as_str())) + .collect::>(); + let planned_provider_order = plan + .providers() + .iter() + .enumerate() + .map(|(index, provider)| (provider.id.as_str().to_string(), index)) + .collect::>(); + let mut pending_requests = Vec::new(); + let mut planned_backend_to_provider = HashMap::new(); + let mut reserved_backend_names = HashSet::new(); + let mut immediate_response_count = 0usize; + + for input in routed.inputs() { + let Some(provider) = self + .planned_providers + .iter() + .find(|provider| provider.provider_name() == input.provider_id().as_str()) + .cloned() + else { + completed_responses.push(provider_launch_failed_response( + input.provider_id().as_str(), + 0, + )); + continue; + }; + let logical_budget_ms = + remaining_budget_ms(auction_start, context.timeout_ms).min(provider.timeout_ms()); + if logical_budget_ms == 0 { + completed_responses.push(provider_timeout_response(provider.provider_name(), 0)); + continue; + } + let transport_timeout_ms = context + .services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, provider.timeout_ms()); + let started_at = Instant::now(); + match provider + .request_bids_routed( + input, + &routed, + logical_budget_ms, + transport_timeout_ms, + signer.as_ref(), + context.services, + &mut reserved_backend_names, + ) + .await + { + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => { + let Some(backend_name) = pending.backend_name().map(str::to_string) else { + completed_responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + continue; + }; + match planned_backend_to_provider.entry(backend_name.clone()) { + Entry::Vacant(entry) => { + entry.insert(PlannedLaunchState { + provider, + started_at, + parse_state, + }); + pending_requests.push(pending.with_backend_name(backend_name)); + } + Entry::Occupied(_) => { + completed_responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )) + } + } + } + Ok(ProviderRequestOutcome::Immediate(response)) => { + immediate_response_count += 1; + completed_responses.push(response); + } + Err(error) => { + log::warn!( + "Planned provider '{}' failed to dispatch: {error:?}", + provider.provider_name() + ); + completed_responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + } + } + } + + if pending_requests.is_empty() + && immediate_response_count == 0 + && completed_responses.is_empty() + { + return DispatchAuctionOutcome::NotStarted; + } + + DispatchAuctionOutcome::Dispatched(DispatchedAuction { + pending_requests, + backend_to_provider: HashMap::new(), + planned_backend_to_provider, + completed_responses, + auction_start, + timeout_ms: context.timeout_ms, + floor_prices: self.floor_prices_by_slot(request), + provider_request_context: Box::new(snapshot_context_request(context.request)), + request: request.clone(), + planned_unused_bidder_params, + planned_unroutable_bidder_count, + planned_provider_order, + }) + } + /// Dispatch SSP bid requests without blocking WASM. /// /// Calls each enabled provider's [`AuctionProvider::request_bids`] (which @@ -940,7 +1797,20 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> DispatchAuctionOutcome { - let provider_names = self.config.provider_names(); + if !self.enabled { + return DispatchAuctionOutcome::NotStarted; + } + if !cfg!(test) || self.plan_backed { + return self.dispatch_planned_auction(request, context).await; + } + #[cfg(test)] + let provider_names = self + .config + .providers + .keys() + .map(super::plan::ProviderId::as_str) + .collect::>(); + #[cfg(test)] if provider_names.is_empty() { return DispatchAuctionOutcome::NotStarted; } @@ -950,6 +1820,7 @@ impl AuctionOrchestrator { // (e.g. Cloudflare Workers, Spin). Sequential execution would accrue // the sum of provider latencies before the origin fetch and then fail // collection with empty bids. + #[cfg(test)] if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout() { log::warn!( @@ -963,13 +1834,26 @@ impl AuctionOrchestrator { } let auction_start = Instant::now(); + #[cfg(test)] let mut backend_to_provider: HashMap = HashMap::new(); + #[cfg(not(test))] + let backend_to_provider: HashMap = HashMap::new(); + #[cfg(test)] let mut pending_requests: Vec = Vec::new(); + #[cfg(not(test))] + let pending_requests: Vec = Vec::new(); + #[cfg(test)] let mut completed_responses: Vec = Vec::new(); + #[cfg(not(test))] + let completed_responses: Vec = Vec::new(); + #[cfg(test)] let mut immediate_response_count = 0usize; + #[cfg(not(test))] + let immediate_response_count = 0usize; - for provider_name in provider_names { - let provider = match self.providers.get(provider_name) { + #[cfg(test)] + for provider_name in &provider_names { + let provider = match self.providers.get(*provider_name) { Some(p) => p, None => { // lgtm[rust/cleartext-logging] @@ -1025,6 +1909,7 @@ impl AuctionOrchestrator { settings: context.settings, request: context.request, timeout_ms: effective_timeout, + transport_timeout_ms: effective_timeout, provider_responses: context.provider_responses, services: context.services, }; @@ -1112,6 +1997,8 @@ impl AuctionOrchestrator { DispatchAuctionOutcome::DispatchFailed { request: request.clone(), provider_responses: completed_responses, + fatal_admission_error: None, + metadata: HashMap::new(), elapsed_ms: auction_start.elapsed().as_millis() as u64, } }; @@ -1127,12 +2014,16 @@ impl AuctionOrchestrator { DispatchAuctionOutcome::Dispatched(DispatchedAuction { pending_requests, backend_to_provider, + planned_backend_to_provider: HashMap::new(), completed_responses, auction_start, timeout_ms: context.timeout_ms, floor_prices: self.floor_prices_by_slot(request), provider_request_context: Box::new(snapshot_context_request(context.request)), request: request.clone(), + planned_unused_bidder_params: HashMap::new(), + planned_unroutable_bidder_count: 0, + planned_provider_order: HashMap::new(), }) } @@ -1155,12 +2046,16 @@ impl AuctionOrchestrator { let DispatchedAuction { pending_requests, mut backend_to_provider, + mut planned_backend_to_provider, completed_responses, auction_start, timeout_ms, floor_prices, provider_request_context, request, + planned_unused_bidder_params, + planned_unroutable_bidder_count, + planned_provider_order, } = dispatched; log::info!( @@ -1172,6 +2067,7 @@ impl AuctionOrchestrator { let mut responses: Vec = completed_responses; let mut remaining = pending_requests; + let deadline_policy = AuctionDeadlinePolicy::for_runtime(services); while !remaining.is_empty() { let select_result = match services @@ -1201,10 +2097,18 @@ impl AuctionOrchestrator { let backend_name = platform_response.backend_name.clone().unwrap_or_default(); if let Some(state) = backend_to_provider.remove(&backend_name) { let response_time_ms = state.started_at.elapsed().as_millis() as u64; + if deadline_policy.rejects_late_completion(auction_start, timeout_ms) { + responses.push(provider_timeout_response( + &state.provider_name, + response_time_ms, + )); + continue; + } let provider_context = AuctionContext { settings: context.settings, request: &provider_request_context, timeout_ms: state.effective_timeout_ms, + transport_timeout_ms: state.effective_timeout_ms, provider_responses: context.provider_responses, services: context.services, }; @@ -1219,28 +2123,39 @@ impl AuctionOrchestrator { ) .await { - Ok(auction_response) => { - log::info!( - "Provider '{}' returned {} bids ({}ms)", - auction_response.provider, - auction_response.bids.len(), - auction_response.response_time_ms - ); - responses.push(auction_response); - } - Err(e) => { - log::warn!( - "Provider '{}' parse failed: {:?}", - state.provider_name, - e - ); - responses.push(provider_error_response( - &state.provider_name, - response_time_ms, - ERROR_TYPE_PARSE_RESPONSE, - &e, - )); - } + Ok(auction_response) => responses.push(auction_response), + Err(error) => responses.push(provider_error_response( + &state.provider_name, + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &error, + )), + } + } else if let Some(state) = planned_backend_to_provider.remove(&backend_name) { + let response_time_ms = state.started_at.elapsed().as_millis() as u64; + if deadline_policy.rejects_late_completion(auction_start, timeout_ms) { + responses.push(provider_timeout_response( + state.provider.provider_name(), + response_time_ms, + )); + continue; + } + match state + .provider + .parse_response_with_state( + platform_response, + response_time_ms, + state.parse_state.as_deref(), + ) + .await + { + Ok(response) => responses.push(response), + Err(error) => responses.push(provider_error_response( + state.provider.provider_name(), + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &error, + )), } } else { log::warn!( @@ -1265,6 +2180,18 @@ impl AuctionOrchestrator { &state.provider_name, response_time_ms, )); + } else if let Some(state) = planned_backend_to_provider.remove(backend_name) + { + let response_time_ms = state.started_at.elapsed().as_millis() as u64; + log::warn!( + "Planned provider '{}' request failed: {:?}", + state.provider.provider_name(), + e + ); + responses.push(provider_transport_failed_response( + state.provider.provider_name(), + response_time_ms, + )); } else { log::warn!( "A provider request failed (backend '{}' not tracked): {:?}", @@ -1302,79 +2229,121 @@ impl AuctionOrchestrator { )); } backend_to_provider.clear(); + for state in planned_backend_to_provider.into_values() { + responses.push(provider_timeout_response( + state.provider.provider_name(), + state.started_at.elapsed().as_millis() as u64, + )); + } + for response in &mut responses { + if let Some(&count) = planned_unused_bidder_params.get(response.provider.as_str()) { + *response = materialize_planned_response(response.clone(), count); + } + } + if !planned_provider_order.is_empty() { + responses.sort_by_key(|response| { + planned_provider_order + .get(response.provider.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + } - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - match self.providers.get(mediator_name.as_str()) { - Some(mediator) => { - // Cap the mediator at whichever is tighter: its own configured - // timeout or the remaining auction budget (A_deadline). Backend - // first-byte and between-bytes timeouts bound normal collection, but - // they are transport timers rather than absolute wall-clock limits: - // connection setup and byte-trickling can still consume more of the - // auction budget. Recomputing the remaining budget here prevents the - // mediator from extending that bounded response hold. - let remaining = remaining_budget_ms(auction_start, timeout_ms); - let mediator_timeout = services - .backend() - .canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms()); - if mediator_timeout == 0 { - log::warn!( - "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", - mediator.provider_name(), - responses.len(), - ); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - }; - } - let mediator_start = Instant::now(); - log::info!( - "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", + #[cfg(not(test))] + let mediator = self.mediator.as_ref(); + #[cfg(test)] + let mediator = self.mediator.as_ref().or_else(|| { + self.config + .mediator + .as_ref() + .and_then(|name| self.providers.get(name)) + }); + let (mediator_response, winning_bids) = if let Some(mediator) = mediator { + { + // Cap the mediator at whichever is tighter: its own configured + // timeout or the remaining auction budget (A_deadline). Backend + // first-byte and between-bytes timeouts bound normal collection, but + // they are transport timers rather than absolute wall-clock limits: + // connection setup and byte-trickling can still consume more of the + // auction budget. Recomputing the remaining budget here prevents the + // mediator from extending that bounded response hold. + let remaining = remaining_budget_ms(auction_start, timeout_ms); + let logical_budget_ms = remaining.min(mediator.timeout_ms()); + if logical_budget_ms == 0 { + log::warn!( + "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", mediator.provider_name(), - mediator_timeout, - remaining, - mediator.timeout_ms(), + responses.len(), ); - // The mediator runs on the collect path. See the doc-comment on - // `AuctionContext::request`: the real client request was already - // consumed by `send_async` during dispatch, so we substitute a - // canonical placeholder URL. Any future mediator that needs real - // client headers must snapshot them at dispatch time onto - // `DispatchedAuction` rather than reading `context.request` here. - let placeholder = http::Request::builder() - .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) - .body(edgezero_core::body::Body::empty()) - .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); - let mediator_context = AuctionContext { - settings: context.settings, - request: &placeholder, - timeout_ms: mediator_timeout, - provider_responses: Some(&responses), - services: context.services, + let winning = self.select_winning_bids(&responses, &floor_prices); + return OrchestrationResult { + provider_responses: responses, + mediator_response: None, + winning_bids: winning, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: routing_metadata(planned_unroutable_bidder_count), }; - let mediator_response = - match mediator.request_bids(&request, &mediator_context).await { - Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), - Ok(ProviderRequestOutcome::Pending { - request: pending, - parse_state, - }) => match services.http_client().wait(pending).await.change_context( - TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", - mediator.provider_name() - ), - }, - ) { - Ok(platform_resp) => match mediator + } + let transport_timeout_ms = services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); + let mediator_start = Instant::now(); + log::info!( + "Running mediator '{}' with {}ms logical budget and {}ms transport timeout (A_deadline remaining: {}ms, configured: {}ms)", + mediator.provider_name(), + logical_budget_ms, + transport_timeout_ms, + remaining, + mediator.timeout_ms(), + ); + // The mediator runs on the collect path. See the doc-comment on + // `AuctionContext::request`: the real client request was already + // consumed by `send_async` during dispatch, so we substitute a + // canonical placeholder URL. Any future mediator that needs real + // client headers must snapshot them at dispatch time onto + // `DispatchedAuction` rather than reading `context.request` here. + let placeholder = http::Request::builder() + .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) + .body(edgezero_core::body::Body::empty()) + .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); + let mediator_context = AuctionContext { + settings: context.settings, + request: &placeholder, + timeout_ms: logical_budget_ms, + transport_timeout_ms, + provider_responses: Some(&responses), + services: context.services, + }; + let mediator_response = match mediator + .request_bids(&request, &mediator_context) + .await + { + Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => match services.http_client().wait(pending).await.change_context( + TrustedServerError::Auction { + message: format!( + "Mediator {} request failed", + mediator.provider_name() + ), + }, + ) { + Ok(platform_resp) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + if deadline_policy.rejects_late_completion(auction_start, timeout_ms) { + log::warn!( + "Mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", + mediator.provider_name(), + response_time_ms + ); + None + } else { + match mediator .parse_response_with_context_and_state( platform_resp, - mediator_start.elapsed().as_millis() as u64, + response_time_ms, &request, &mediator_context, parse_state.as_deref(), @@ -1390,24 +2359,26 @@ impl AuctionOrchestrator { ); None } - }, - Err(error) => { - log::warn!("Mediator request failed: {:?}", error); - None } - }, - Err(error) => { - log::warn!( - "Mediator '{}' failed to dispatch: {:?}", - mediator.provider_name(), - error - ); - None } - }; + } + Err(error) => { + log::warn!("Mediator request failed: {:?}", error); + None + } + }, + Err(error) => { + log::warn!( + "Mediator '{}' failed to dispatch: {:?}", + mediator.provider_name(), + error + ); + None + } + }; - if let Some(mediator_response) = mediator_response { - let winning = mediator_response + if let Some(mediator_response) = mediator_response { + let winning = mediator_response .bids .iter() .filter_map(|bid| { @@ -1423,16 +2394,9 @@ impl AuctionOrchestrator { } }) .collect(); - let winning = self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_response), winning) - } else { - (None, self.select_winning_bids(&responses, &floor_prices)) - } - } - None => { - // lgtm[rust/cleartext-logging] - // The mediator name is a static config identifier, not a secret. - log::warn!("Mediator '{}' not registered", mediator_name); + let winning = self.apply_floor_prices(winning, &floor_prices); + (Some(mediator_response), winning) + } else { (None, self.select_winning_bids(&responses, &floor_prices)) } } @@ -1445,14 +2409,14 @@ impl AuctionOrchestrator { mediator_response, winning_bids, total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), + metadata: routing_metadata(planned_unroutable_bidder_count), } } /// Check if orchestrator is enabled. #[must_use] pub fn is_enabled(&self) -> bool { - self.config.enabled + self.enabled } } @@ -1472,6 +2436,16 @@ pub struct OrchestrationResult { } impl OrchestrationResult { + fn no_bid() -> Self { + Self { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + } + } + /// Get the winning bid for a specific slot. #[must_use] pub fn get_winning_bid(&self, slot_id: &str) -> Option<&Bid> { @@ -1497,50 +2471,504 @@ impl OrchestrationResult { #[cfg(test)] mod tests { + use std::str::FromStr as _; use std::time::Duration; + + use base64::Engine as _; use web_time::Instant; use crate::auction::config::AuctionConfig; use crate::auction::orchestrator::DispatchAuctionOutcome; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; + use crate::auction::plan::{ + AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, + }; + use crate::auction::provider::{ + AuctionProvider, GenericOpenRtbProvider, ProviderRequestOutcome, + }; + use crate::auction::routing::{RoutingDiagnostics, route_auction}; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; + use crate::integrations::adserver_mock::{AdServerMockConfig, AdServerMockProvider}; use crate::platform::test_support::{ StubHttpClient, build_services_with_backend_and_http_client, build_services_with_http_client, noop_services, }; use crate::platform::{ - PlatformBackend, PlatformBackendSpec, PlatformError, PlatformHttpRequest, PlatformResponse, - RuntimeServices, + BackendNamingPolicy, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformHttpRequest, PlatformResponse, PlatformSecretStore, RuntimeServices, + StoreId, StoreName, }; use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; - use std::collections::{HashMap, HashSet}; + use std::collections::{BTreeMap, HashMap, HashSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; - use super::AuctionOrchestrator; + use super::{ + AuctionOrchestrator, AuctionOrchestratorHarness, DispatchedAuction, ERROR_TYPE_TIMEOUT, + OrchestrationResult, + }; - // --------------------------------------------------------------------------- - // Minimal test double for AuctionProvider - // --------------------------------------------------------------------------- + fn planned_config(providers: &[(&str, RoutingMode)], signing: bool) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 777, + providers: providers + .iter() + .map(|(id, routing)| { + ( + ProviderId::from_str(id).expect("should parse fictional provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "standard".to_string(), + endpoint: "https://example.test/openrtb".to_string(), + timeout_ms: Some(1_000), + routing: *routing, + notifications: Default::default(), + profile_config: serde_json::json!({}), + }, + ) + }) + .collect(), + bidders: BTreeMap::new(), + mediator: None, + request_signing: signing.then(|| crate::settings::RequestSigning { + enabled: true, + config_store_id: "fictional-config-store".to_string(), + secret_store_id: "fictional-secret-store".to_string(), + }), + } + } - struct StubAuctionProvider { - name: &'static str, - backend: &'static str, + fn planned_prebid_config( + providers: &[(&str, serde_json::Value, NotificationConfig)], + ) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 777, + providers: providers + .iter() + .map(|(id, profile_config, notifications)| { + ( + ProviderId::from_str(id).expect("should parse fictional provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "prebid-server".to_string(), + endpoint: format!("https://{id}.example.test/openrtb"), + timeout_ms: Some(1_000), + routing: RoutingMode::AllEligible, + notifications: notifications.clone(), + profile_config: profile_config.clone(), + }, + ) + }) + .collect(), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, + } } - #[async_trait::async_trait(?Send)] - impl AuctionProvider for StubAuctionProvider { - fn provider_name(&self) -> &'static str { - self.name + fn planned_aps_config() -> AuctionPlanConfig { + planned_aps_instances_config(&[( + "aps-instance", + serde_json::json!({"account_id": "example-account"}), + NotificationConfig::default(), + )]) + } + + fn planned_aps_instances_config( + providers: &[(&str, serde_json::Value, NotificationConfig)], + ) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 777, + providers: providers + .iter() + .map(|(id, profile_config, notifications)| { + ( + ProviderId::from_str(id).expect("should parse fictional provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: Some(1_000), + routing: RoutingMode::AllEligible, + notifications: notifications.clone(), + profile_config: profile_config.clone(), + }, + ) + }) + .collect(), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, } + } - async fn request_bids( - &self, + fn planned_request() -> AuctionRequest { + AuctionRequest { + id: "fictional-auction".to_string(), + slots: vec![AdSlot { + id: "fictional-slot".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: Some(1.0), + targeting: HashMap::new(), + bidders: HashMap::new(), + }], + publisher: PublisherInfo { + domain: "publisher.example".to_string(), + page_url: Some("https://publisher.example/article".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + #[tokio::test] + async fn disabled_from_plan_is_a_no_work_kill_switch_for_sync_and_split_paths() { + let plan = Arc::new( + AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile plan") + .with_enabled(false), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + let request = planned_request(); + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("disabled auction should complete as no-bid"); + assert!(result.provider_responses.is_empty()); + assert!(result.winning_bids.is_empty()); + assert!(matches!( + orchestrator.dispatch_auction(&request, &context).await, + DispatchAuctionOutcome::NotStarted + )); + assert!(http.recorded_backend_names().is_empty()); + } + + #[tokio::test] + async fn enabled_empty_plan_is_successful_no_bid_without_dispatch() { + let plan = Arc::new( + AuctionPlan::compile(planned_config(&[], false)).expect("should compile empty plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + let request = planned_request(); + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("empty auction should complete as no-bid"); + assert!(result.provider_responses.is_empty()); + assert!(result.winning_bids.is_empty()); + assert!(matches!( + orchestrator.dispatch_auction(&request, &context).await, + DispatchAuctionOutcome::NotStarted + )); + assert!(http.recorded_backend_names().is_empty()); + } + + #[tokio::test] + async fn all_skipped_from_plan_completes_with_routing_metadata_in_sync_and_split_paths() { + for split in [false, true] { + let plan = Arc::new( + AuctionPlan::compile(planned_config(&[("skipped", RoutingMode::Explicit)], false)) + .expect("should compile all-skipped plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + let request = planned_request(); + + let result = if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("all-skipped auction should produce a completed dispatch token"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("all-skipped auction should complete") + }; + + assert_eq!(result.provider_responses.len(), 1); + assert_eq!( + result.provider_responses[0].metadata["routing"]["skipped_no_eligible_slots"], + true + ); + assert!(result.metadata.contains_key("routing")); + assert!(http.recorded_backend_names().is_empty()); + } + } + + struct NamingBackend { + policy: BackendNamingPolicy, + predicted: AtomicUsize, + ensured: AtomicUsize, + specs: Mutex>, + fail_ensure_for: Mutex>, + } + + impl NamingBackend { + fn new(policy: BackendNamingPolicy) -> Self { + Self { + policy, + predicted: AtomicUsize::new(0), + ensured: AtomicUsize::new(0), + specs: Mutex::new(Vec::new()), + fail_ensure_for: Mutex::new(HashSet::new()), + } + } + + fn fail_ensure_for(&self, provider_id: &str) { + self.fail_ensure_for + .lock() + .expect("should lock failing provider IDs") + .insert(provider_id.to_string()); + } + + fn name(&self, spec: &PlatformBackendSpec) -> Result> { + self.policy + .predict(spec) + .map(|prediction| prediction.name) + .change_context(PlatformError::Backend) + } + } + + impl PlatformBackend for NamingBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + self.policy + } + + fn predict_name( + &self, + spec: &PlatformBackendSpec, + ) -> Result> { + self.predicted.fetch_add(1, Ordering::Relaxed); + self.name(spec) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.ensured.fetch_add(1, Ordering::Relaxed); + if spec.discriminator.as_deref().is_some_and(|provider_id| { + self.fail_ensure_for + .lock() + .expect("should lock failing provider IDs") + .contains(provider_id) + }) { + return Err(Report::new(PlatformError::Backend)); + } + self.specs + .lock() + .expect("should lock planned backend specs") + .push(spec.clone()); + self.name(spec) + } + } + + struct CollidingBackend; + + impl PlatformBackend for CollidingBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Axum + } + + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("colliding-backend".to_string()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("colliding-backend".to_string()) + } + } + + struct FailingCountingConfigStore { + reads: AtomicUsize, + } + + impl PlatformConfigStore for FailingCountingConfigStore { + fn get( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result> { + self.reads.fetch_add(1, Ordering::Relaxed); + Err(Report::new(PlatformError::ConfigStore)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct CountingConfigStore { + reads: AtomicUsize, + current_kid: String, + delay: Duration, + } + + impl PlatformConfigStore for CountingConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.reads.fetch_add(1, Ordering::Relaxed); + if !self.delay.is_zero() { + std::thread::sleep(self.delay); + } + (key == "current-kid") + .then(|| self.current_kid.clone()) + .ok_or_else(|| Report::new(PlatformError::ConfigStore)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct CountingSecretStore { + reads: AtomicUsize, + key: Vec, + } + + impl PlatformSecretStore for CountingSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + self.reads.fetch_add(1, Ordering::Relaxed); + Ok(self.key.clone()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct UnusedSecretStore; + + impl PlatformSecretStore for UnusedSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + panic!("signing key should not be read after current-kid failure") + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + // --------------------------------------------------------------------------- + // Minimal test double for AuctionProvider + // --------------------------------------------------------------------------- + + struct StubAuctionProvider { + name: &'static str, + backend: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for StubAuctionProvider { + fn provider_name(&self) -> &str { + self.name + } + + async fn request_bids( + &self, _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { @@ -1603,17 +3031,14 @@ mod tests { } } - struct RecordingTimeoutProvider { + struct DeadlineBidProvider { name: &'static str, backend: &'static str, - configured_timeout_ms: u32, - predicted: Arc>>, - requested: Arc>>, } #[async_trait::async_trait(?Send)] - impl AuctionProvider for RecordingTimeoutProvider { - fn provider_name(&self) -> &'static str { + impl AuctionProvider for DeadlineBidProvider { + fn provider_name(&self) -> &str { self.name } @@ -1622,16 +3047,12 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - self.requested - .lock() - .expect("should lock requested timeouts") - .push(context.timeout_ms); let request = PlatformHttpRequest::new( http::Request::builder() .method("POST") .uri("https://example.com/bid") .body(edgezero_core::body::Body::empty()) - .expect("should build recording request"), + .expect("should build deadline test request"), self.backend, ); context @@ -1640,7 +3061,7 @@ mod tests { .send_async(request) .await .change_context(TrustedServerError::Auction { - message: "recording launch failed".to_string(), + message: "deadline test provider launch failed".to_string(), }) .map(ProviderRequestOutcome::pending) } @@ -1652,34 +3073,33 @@ mod tests { ) -> Result> { Ok(AuctionResponse::success( self.name, - vec![], + vec![auction_bid(self.name, 3.0)], response_time_ms, )) } fn timeout_ms(&self) -> u32 { - self.configured_timeout_ms + 1_000 } - fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { - self.predicted - .lock() - .expect("should lock predicted timeouts") - .push(timeout_ms); + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { Some(self.backend.to_string()) } } - struct DivergentBackendProvider { - name: &'static str, - predicted: &'static str, - resolved: &'static str, + type RecordedMediatorBudgets = Arc>>; + + struct DeadlineRecordingMediator { + launches: Arc, + budgets: Option, } + struct PendingDeadlineMediator; + #[async_trait::async_trait(?Send)] - impl AuctionProvider for DivergentBackendProvider { - fn provider_name(&self) -> &'static str { - self.name + impl AuctionProvider for PendingDeadlineMediator { + fn provider_name(&self) -> &str { + "pending-deadline-mediator" } async fn request_bids( @@ -1690,10 +3110,10 @@ mod tests { let request = PlatformHttpRequest::new( http::Request::builder() .method("POST") - .uri("https://example.com/bid") + .uri("https://example.com/mediate") .body(edgezero_core::body::Body::empty()) - .expect("should build divergent request"), - self.resolved, + .expect("should build pending mediator request"), + "pending-mediator-backend", ); context .services @@ -1701,7 +3121,7 @@ mod tests { .send_async(request) .await .change_context(TrustedServerError::Auction { - message: "divergent launch failed".to_string(), + message: "pending mediator launch failed".to_string(), }) .map(ProviderRequestOutcome::pending) } @@ -1712,65 +3132,230 @@ mod tests { response_time_ms: u64, ) -> Result> { Ok(AuctionResponse::success( - self.name, - vec![], + self.provider_name(), + vec![auction_bid("mediated", 9.0)], response_time_ms, )) } fn timeout_ms(&self) -> u32 { - 2000 + 1_000 } fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some(self.predicted.to_string()) + Some("pending-mediator-backend".to_string()) } } - struct CanonicalTimeoutBackend { - canonical_ms: u32, - calls: Arc>>, - } + #[async_trait::async_trait(?Send)] + impl AuctionProvider for DeadlineRecordingMediator { + fn provider_name(&self) -> &str { + "deadline-mediator" + } - impl PlatformBackend for CanonicalTimeoutBackend { - fn predict_name( + async fn request_bids( &self, - _spec: &PlatformBackendSpec, - ) -> Result> { - Ok("stub-backend".to_string()) + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + self.launches.fetch_add(1, Ordering::Relaxed); + if let Some(budgets) = &self.budgets { + budgets + .lock() + .expect("should lock mediator budgets") + .push((context.timeout_ms, context.transport_timeout_ms)); + } + Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( + self.provider_name(), + 0, + ))) } - fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { - Ok("stub-backend".to_string()) + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("immediate mediator response should not be parsed"); } - fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { - self.calls - .lock() - .expect("should lock canonicalization calls") - .push((remaining_ms, configured_ms)); - self.canonical_ms + fn timeout_ms(&self) -> u32 { + 1_000 } } - fn recording_provider( + struct RecordingTimeoutProvider { name: &'static str, backend: &'static str, configured_timeout_ms: u32, - predicted: &Arc>>, - requested: &Arc>>, - ) -> RecordingTimeoutProvider { - RecordingTimeoutProvider { - name, - backend, - configured_timeout_ms, - predicted: Arc::clone(predicted), - requested: Arc::clone(requested), - } + predicted: Arc>>, + requested: Arc>>, } - /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring - /// `adserver_mock`), while its context-free parse does not. Lets a test prove + #[async_trait::async_trait(?Send)] + impl AuctionProvider for RecordingTimeoutProvider { + fn provider_name(&self) -> &str { + self.name + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + self.requested + .lock() + .expect("should lock requested timeouts") + .push(context.transport_timeout_ms); + let request = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(edgezero_core::body::Body::empty()) + .expect("should build recording request"), + self.backend, + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "recording launch failed".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + self.name, + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + self.configured_timeout_ms + } + + fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { + self.predicted + .lock() + .expect("should lock predicted timeouts") + .push(timeout_ms); + Some(self.backend.to_string()) + } + } + + struct DivergentBackendProvider { + name: &'static str, + predicted: &'static str, + resolved: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for DivergentBackendProvider { + fn provider_name(&self) -> &str { + self.name + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let request = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(edgezero_core::body::Body::empty()) + .expect("should build divergent request"), + self.resolved, + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "divergent launch failed".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + self.name, + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some(self.predicted.to_string()) + } + } + + struct CanonicalTimeoutBackend { + canonical_ms: u32, + calls: Arc>>, + } + + impl PlatformBackend for CanonicalTimeoutBackend { + fn naming_policy(&self) -> crate::platform::BackendNamingPolicy { + crate::platform::BackendNamingPolicy::Axum + } + + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("stub-backend".to_string()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("stub-backend".to_string()) + } + + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + self.calls + .lock() + .expect("should lock canonicalization calls") + .push((remaining_ms, configured_ms)); + self.canonical_ms + } + } + + fn recording_provider( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + predicted: &Arc>>, + requested: &Arc>>, + ) -> RecordingTimeoutProvider { + RecordingTimeoutProvider { + name, + backend, + configured_timeout_ms, + predicted: Arc::clone(predicted), + requested: Arc::clone(requested), + } + } + + /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring + /// `adserver_mock`), while its context-free parse does not. Lets a test prove /// the synchronous mediation path calls `parse_response_with_context`. struct CacheRestoringMediator; @@ -1797,6 +3382,7 @@ mod tests { .then(|| "
ordinary
".to_string()), adomain: None, bidder: bidder.to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -1820,6 +3406,7 @@ mod tests { creative: Some("
ad
".to_string()), adomain: None, bidder: "mediator".to_string(), + returned_seat: None, width: 728, height: 90, nurl: nurl.clone(), @@ -1837,7 +3424,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for CacheRestoringMediator { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "mediator" } @@ -1906,7 +3493,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for ImmediateMediator { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "immediate-mediator" } @@ -1952,7 +3539,7 @@ mod tests { let config = AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], + providers: AuctionConfig::legacy_provider_map(&["bidder"]), mediator: Some("mediator".to_string()), timeout_ms: 2000, ..Default::default() @@ -1975,6 +3562,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -2008,7 +3596,7 @@ mod tests { let services = build_services_with_http_client(stub); let config = AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], + providers: AuctionConfig::legacy_provider_map(&["bidder"]), mediator: Some("immediate-mediator".to_string()), timeout_ms: 2000, ..Default::default() @@ -2026,6 +3614,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services: &services, }; @@ -2063,69 +3652,329 @@ mod tests { } } - fn create_test_auction_request() -> AuctionRequest { - AuctionRequest { - id: "test-auction-123".to_string(), - slots: vec![ - AdSlot { - id: "header-banner".to_string(), - formats: vec![AdFormat { - media_type: MediaType::Banner, - width: 728, - height: 90, - }], - floor_price: Some(1.50), - targeting: HashMap::new(), - bidders: HashMap::new(), - }, - AdSlot { - id: "sidebar".to_string(), - formats: vec![AdFormat { - media_type: MediaType::Banner, - width: 300, - height: 250, - }], - floor_price: Some(1.00), - targeting: HashMap::new(), - bidders: HashMap::new(), - }, - ], - publisher: PublisherInfo { - domain: "test.com".to_string(), - page_url: Some("https://test.com/article".to_string()), - }, - user: UserInfo { - id: Some("user-123".to_string()), - consent: None, - eids: None, - }, - device: None, - site: None, - context: HashMap::new(), + async fn collect_deadline_test_result( + split: bool, + enforceable_total_request_deadline: bool, + ) -> OrchestrationResult { + let stub = Arc::new(StubHttpClient::new()); + stub.set_enforceable_total_request_deadline(enforceable_total_request_deadline); + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_delay(Duration::from_millis(50)); + let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["late-one", "late-two"]), + timeout_ms: 10, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "late-one", + backend: "late-one-backend", + })); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "late-two", + backend: "late-two-backend", + })); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 10, + transport_timeout_ms: 10, + provider_responses: None, + services: &services, + }; + + if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("deadline test providers should dispatch"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("deadline test auction should complete") } } - fn create_test_settings() -> crate::settings::Settings { - let settings_str = crate_test_settings_str(); - crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") + #[tokio::test] + async fn current_adapter_deadline_drains_late_responses_in_both_paths() { + for split in [false, true] { + let result = collect_deadline_test_result(split, false).await; + assert_eq!(result.provider_responses.len(), 2); + assert_eq!(result.provider_responses[0].provider, "late-one"); + assert_eq!(result.provider_responses[0].status, BidStatus::Success); + assert_eq!(result.provider_responses[1].provider, "late-two"); + assert_eq!(result.provider_responses[1].status, BidStatus::Success); + assert!( + result + .provider_responses + .iter() + .all(|response| response.response_time_ms >= 50), + "late response times should retain actual elapsed duration" + ); + assert_eq!( + result.winning_bids["slot-1"].bidder, "late-one", + "a completed response remains eligible after the logical deadline" + ); + } } - struct ImmediateNoBidProvider; - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for ImmediateNoBidProvider { - fn provider_name(&self) -> &'static str { - "immediate" + #[tokio::test] + async fn synthetic_hard_deadline_classifies_late_responses_in_both_paths() { + for split in [false, true] { + let result = collect_deadline_test_result(split, true).await; + assert_eq!(result.provider_responses.len(), 2); + assert!(result.provider_responses.iter().all(|response| { + response.status == BidStatus::Error + && response.metadata["error_type"] == ERROR_TYPE_TIMEOUT + && response.response_time_ms >= 50 + })); + assert!(result.winning_bids.is_empty()); } + } - async fn request_bids( - &self, - _request: &AuctionRequest, - _context: &AuctionContext<'_>, - ) -> Result> { - Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( - "immediate", - 0, + async fn pending_mediator_deadline_test_result( + split: bool, + enforceable_total_request_deadline: bool, + ) -> OrchestrationResult { + let stub = Arc::new(StubHttpClient::new()); + stub.set_enforceable_total_request_deadline(enforceable_total_request_deadline); + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_delay(Duration::ZERO); + stub.push_select_delay(Duration::from_millis(50)); + let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["local"]), + mediator: Some("pending-deadline-mediator".to_string()), + timeout_ms: 20, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "local", + backend: "local-backend", + })); + orchestrator.register_provider(Arc::new(PendingDeadlineMediator)); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 20, + transport_timeout_ms: 20, + provider_responses: None, + services: &services, + }; + + if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("deadline test provider should dispatch"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("deadline test auction should complete") + } + } + + #[tokio::test] + async fn pending_mediator_late_completion_policy_is_equivalent_in_sync_and_split_paths() { + for split in [false, true] { + let current = pending_mediator_deadline_test_result(split, false).await; + let current_mediator = current + .mediator_response + .as_ref() + .expect("current adapters should accept completed late mediator responses"); + assert!( + current_mediator.response_time_ms >= 50, + "mediator timing should preserve actual elapsed duration" + ); + assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + + let hard = pending_mediator_deadline_test_result(split, true).await; + assert!(hard.mediator_response.is_none()); + assert_eq!(hard.winning_bids["slot-1"].bidder, "local"); + assert!( + hard.total_time_ms >= 50, + "discarding a late mediator must retain actual total elapsed time" + ); + } + } + + #[tokio::test] + async fn split_deadline_skips_mediator_and_falls_back_to_provider_winner() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_delay(Duration::from_millis(50)); + let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); + let launches = Arc::new(AtomicUsize::new(0)); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["late-one"]), + mediator: Some("deadline-mediator".to_string()), + timeout_ms: 10, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "late-one", + backend: "late-one-backend", + })); + orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { + launches: Arc::clone(&launches), + budgets: None, + })); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 10, + transport_timeout_ms: 10, + provider_responses: None, + services: &services, + }; + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("deadline test provider should dispatch"); + }; + let result = orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await; + + assert_eq!(launches.load(Ordering::Relaxed), 0); + assert!(result.mediator_response.is_none()); + assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); + } + + #[tokio::test] + async fn synchronous_deadline_skips_mediator_and_falls_back_to_provider_winner() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_delay(Duration::from_millis(50)); + let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); + let launches = Arc::new(AtomicUsize::new(0)); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["late-one"]), + mediator: Some("deadline-mediator".to_string()), + timeout_ms: 10, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "late-one", + backend: "late-one-backend", + })); + orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { + launches: Arc::clone(&launches), + budgets: None, + })); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 10, + transport_timeout_ms: 10, + provider_responses: None, + services: &services, + }; + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("synchronous deadline test should complete"); + + assert_eq!(launches.load(Ordering::Relaxed), 0); + assert!(result.mediator_response.is_none()); + assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); + } + + fn create_test_auction_request() -> AuctionRequest { + AuctionRequest { + id: "test-auction-123".to_string(), + slots: vec![ + AdSlot { + id: "header-banner".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 728, + height: 90, + }], + floor_price: Some(1.50), + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + AdSlot { + id: "sidebar".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: Some(1.00), + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + ], + publisher: PublisherInfo { + domain: "test.com".to_string(), + page_url: Some("https://test.com/article".to_string()), + }, + user: UserInfo { + id: Some("user-123".to_string()), + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn create_test_settings() -> crate::settings::Settings { + let settings_str = crate_test_settings_str(); + crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") + } + + struct ImmediateNoBidProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for ImmediateNoBidProvider { + fn provider_name(&self) -> &str { + "immediate" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( + "immediate", + 0, ))) } @@ -2146,7 +3995,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for LaunchFailingProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "launch-failing" } @@ -2188,6 +4037,7 @@ mod tests { settings, request, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, } @@ -2197,7 +4047,7 @@ mod tests { async fn synchronous_auction_accepts_an_all_immediate_no_bid_result() { let config = AuctionConfig { enabled: true, - providers: vec!["immediate".to_string()], + providers: AuctionConfig::legacy_provider_map(&["immediate"]), timeout_ms: 2000, ..Default::default() }; @@ -2222,7 +4072,7 @@ mod tests { async fn split_auction_accepts_an_all_immediate_no_bid_result() { let config = AuctionConfig { enabled: true, - providers: vec!["immediate".to_string()], + providers: AuctionConfig::legacy_provider_map(&["immediate"]), timeout_ms: 2000, ..Default::default() }; @@ -2253,7 +4103,7 @@ mod tests { for split in [false, true] { let config = AuctionConfig { enabled: true, - providers: vec!["immediate".to_string(), "pending".to_string()], + providers: AuctionConfig::legacy_provider_map(&["immediate", "pending"]), timeout_ms: 2000, ..Default::default() }; @@ -2411,6 +4261,7 @@ mod tests { creative: Some("
Ad
".to_string()), adomain: None, bidder: "test-bidder".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -2434,6 +4285,7 @@ mod tests { creative: Some("
Ad
".to_string()), adomain: None, bidder: "test-bidder".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -2478,7 +4330,8 @@ mod tests { let config = AuctionConfig { enabled: true, rewrite_creatives: true, - providers: vec![], + providers: AuctionConfig::legacy_provider_map(&[]), + bidders: Default::default(), mediator: None, timeout_ms: 2000, creative_store: "creative_store".to_string(), @@ -2509,7 +4362,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, - providers: vec!["launch-failing".to_string()], + providers: AuctionConfig::legacy_provider_map(&["launch-failing"]), timeout_ms: 2000, ..Default::default() }; @@ -2539,41 +4392,12 @@ mod tests { }); } - #[test] - fn rejects_duplicate_configured_providers() { - let config = AuctionConfig { - enabled: true, - providers: vec!["prebid".to_string(), "prebid".to_string()], - timeout_ms: 2000, - ..Default::default() - }; - let err = AuctionOrchestrator::new(config) - .validate_configured_provider_names() - .expect_err("should reject a provider listed more than once"); - assert!(err.to_string().contains("listed more than once")); - } - - #[test] - fn rejects_mediator_also_listed_as_provider() { - let config = AuctionConfig { - enabled: true, - providers: vec!["prebid".to_string()], - mediator: Some("prebid".to_string()), - timeout_ms: 2000, - ..Default::default() - }; - let err = AuctionOrchestrator::new(config) - .validate_configured_provider_names() - .expect_err("should reject a mediator also configured as a provider"); - assert!(err.to_string().contains("may not mediate its own auction")); - } - #[tokio::test] async fn duplicate_backend_name_fails_second_provider_attributably_in_both_paths() { for split in [false, true] { let config = AuctionConfig { enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], + providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), timeout_ms: 2000, ..Default::default() }; @@ -2688,7 +4512,7 @@ mod tests { let requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], + providers: AuctionConfig::legacy_provider_map(&["bidder"]), timeout_ms: 2000, ..Default::default() }); @@ -2732,7 +4556,7 @@ mod tests { let requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], + providers: AuctionConfig::legacy_provider_map(&["bidder"]), timeout_ms: 2000, ..Default::default() }); @@ -2775,7 +4599,7 @@ mod tests { let requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], + providers: AuctionConfig::legacy_provider_map(&["bidder"]), mediator: Some("mediator".to_string()), timeout_ms: 2000, ..Default::default() @@ -2825,7 +4649,7 @@ mod tests { let mediator_requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], + providers: AuctionConfig::legacy_provider_map(&["bidder"]), mediator: Some("mediator".to_string()), timeout_ms: 2000, ..Default::default() @@ -2879,6 +4703,65 @@ mod tests { }); } + #[test] + fn planned_collect_launches_mediator_with_positive_sub_quantum_logical_budget() { + futures::executor::block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let services = build_services_with_backend_and_http_client( + Arc::new(CanonicalTimeoutBackend { + canonical_ms: 0, + calls: Arc::clone(&calls), + }), + Arc::new(StubHttpClient::new()), + ); + let launches = Arc::new(AtomicUsize::new(0)); + let budgets = Arc::new(Mutex::new(Vec::new())); + let plan = AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 49, + providers: BTreeMap::new(), + bidders: BTreeMap::new(), + mediator: Some("adserver_mock".to_string()), + request_signing: None, + }) + .expect("should compile mediator-only plan") + .with_enabled(true); + let orchestrator = AuctionOrchestrator::from_plan( + Arc::new(plan), + Some(Arc::new(DeadlineRecordingMediator { + launches: Arc::clone(&launches), + budgets: Some(Arc::clone(&budgets)), + })), + ); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 49, + transport_timeout_ms: 49, + provider_responses: None, + services: &services, + }; + let dispatched = DispatchedAuction::empty_for_test(request, 49); + + let result = orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await; + + assert_eq!(launches.load(Ordering::Relaxed), 1); + let budgets = budgets.lock().expect("should lock mediator budgets"); + assert_eq!(budgets.len(), 1); + assert!( + (1..50).contains(&budgets[0].0), + "logical budget should remain positive and below the Fastly quantum" + ); + assert_eq!(budgets[0].1, 0); + assert_eq!(calls.lock().expect("should lock calls").len(), 1); + assert!(result.mediator_response.is_some()); + }); + } + #[test] fn dispatched_resolved_backend_name_diverging_from_prediction_still_correlates() { futures::executor::block_on(async { @@ -2887,7 +4770,7 @@ mod tests { let services = build_services_with_http_client(stub); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: vec!["provider-a".to_string()], + providers: AuctionConfig::legacy_provider_map(&["provider-a"]), timeout_ms: 2000, ..Default::default() }); @@ -2925,7 +4808,7 @@ mod tests { let services = build_services_with_http_client(stub); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], + providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), timeout_ms: 2000, ..Default::default() }); @@ -2980,7 +4863,7 @@ mod tests { let config = AuctionConfig { enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], + providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), timeout_ms: 2000, mediator: None, ..Default::default() @@ -3006,6 +4889,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -3055,7 +4939,7 @@ mod tests { let services = build_services_with_http_client(stub); let config = AuctionConfig { enabled: true, - providers: vec!["provider-a".to_string()], + providers: AuctionConfig::legacy_provider_map(&["provider-a"]), timeout_ms: 750, mediator: None, ..Default::default() @@ -3076,6 +4960,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 750, + transport_timeout_ms: 750, provider_responses: None, services: &services, }; @@ -3094,6 +4979,7 @@ mod tests { settings: &settings, request: &placeholder, timeout_ms: 750, + transport_timeout_ms: 750, provider_responses: None, services: &services, }; @@ -3133,7 +5019,7 @@ mod tests { let config = AuctionConfig { enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], + providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), timeout_ms: 2000, mediator: None, ..Default::default() @@ -3159,6 +5045,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -3198,7 +5085,7 @@ mod tests { let config = AuctionConfig { enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], + providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), timeout_ms: 2000, mediator: None, ..Default::default() @@ -3224,6 +5111,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -3243,6 +5131,2015 @@ mod tests { }); } + #[tokio::test] + async fn from_plan_standard_provider_runs_direct_and_split_with_correlation_and_metadata() { + for split in [false, true] { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "provider-seat", "bid": [{ + "id": "provider-bid", "impid": "fictional-slot", "price": 2.0, + "adm": "
provider
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize provider response"), + ); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let mut config = planned_config(&[("provider-a", RoutingMode::AllEligible)], false); + config.bidders.insert( + "routed-bidder" + .parse() + .expect("should parse fictional bidder ID"), + crate::auction::plan::BidderRouteConfig { + provider: "provider-a" + .parse() + .expect("should parse fictional provider ID"), + }, + ); + let plan = Arc::new(AuctionPlan::compile(config).expect("should compile plan")); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let mut request = planned_request(); + request.slots[0].bidders.insert( + "routed-bidder".to_string(), + serde_json::json!({"placement": 7}), + ); + request.slots[0].bidders.insert( + "unknown-private-id".to_string(), + serde_json::json!({"secret": 9}), + ); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("standard provider should dispatch"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("standard provider should run") + }; + + assert_eq!(http.recorded_backend_names().len(), 1); + assert_eq!(backend.ensured.load(Ordering::Relaxed), 1); + assert_eq!(result.provider_responses.len(), 1); + assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!(result.provider_responses[0].status, BidStatus::Success); + assert_eq!( + result.provider_responses[0].metadata["routing"]["unused_bidder_params_count"], + 1 + ); + assert_eq!(result.metadata["routing"]["unroutable_bidder_count"], 1); + assert_eq!( + result.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("provider-bid") + ); + let metadata = + serde_json::to_string(&result.metadata).expect("should serialize auction metadata"); + assert!(!metadata.contains("unknown-private-id") && !metadata.contains("secret")); + } + } + + #[tokio::test] + async fn planned_executor_invokes_immediate_mediator_and_applies_floor() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "provider-seat", "bid": [{ + "id": "provider", "impid": "fictional-slot", "price": 2.0, + "adm": "
provider
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize provider response"), + ); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile planned auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(ImmediateMediator))); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute planned mediation"); + + assert_eq!( + result + .mediator_response + .as_ref() + .map(|response| response.provider.as_str()), + Some("immediate-mediator") + ); + assert_eq!( + result.winning_bids["header-banner"].nurl.as_deref(), + Some("https://nurl.example/immediate") + ); + assert!( + !result.winning_bids.contains_key("fictional-slot"), + "mediator output owns final selection" + ); + } + + async fn planned_pending_mediator_deadline_result( + enforceable_total_request_deadline: bool, + ) -> OrchestrationResult { + let http = Arc::new(StubHttpClient::new()); + http.set_enforceable_total_request_deadline(enforceable_total_request_deadline); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "provider-seat", "bid": [{ + "id": "provider", "impid": "fictional-slot", "price": 2.0, + "adm": "
provider
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize provider response"), + ); + http.push_response(200, b"{}".to_vec()); + http.push_select_delay(Duration::ZERO); + http.push_select_delay(Duration::from_millis(50)); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile planned auction"); + let orchestrator = + AuctionOrchestratorHarness::new(plan, Some(Arc::new(PendingDeadlineMediator))); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 20, + transport_timeout_ms: 20, + provider_responses: None, + services: &services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should execute planned pending mediator") + } + + #[tokio::test] + async fn planned_pending_mediator_applies_explicit_hard_deadline_policy() { + let current = planned_pending_mediator_deadline_result(false).await; + let current_mediator = current + .mediator_response + .as_ref() + .expect("current adapters should accept completed late mediator responses"); + assert!(current_mediator.response_time_ms >= 50); + assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + + let hard = planned_pending_mediator_deadline_result(true).await; + assert!(hard.mediator_response.is_none()); + assert_eq!( + hard.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("provider") + ); + assert!(hard.total_time_ms >= 50); + } + + #[tokio::test] + async fn planned_executor_mediator_transport_failure_falls_back_locally() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "provider-seat", "bid": [{ + "id": "provider", "impid": "fictional-slot", "price": 2.0, + "adm": "
provider
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize provider response"), + ); + http.push_response(200, b"{}".to_vec()); + http.push_select_success(); + http.push_select_error(); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile planned auction"); + let orchestrator = + AuctionOrchestratorHarness::new(plan, Some(Arc::new(CacheRestoringMediator))); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should fall back from mediator transport failure"); + + assert!(result.mediator_response.is_none()); + assert_eq!( + result.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("provider") + ); + } + + #[tokio::test] + async fn planned_prebid_instances_preserve_headers_metadata_suppression_and_identity() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "suppress-exact", "bid": [ + {"id":"good-a","impid":"fictional-slot","price":1.25,"adm":"
a
","w":300,"h":250,"nurl":"https://notify.example/win","burl":"https://notify.example/bill","ext":{"prebid":{"cache":{"bids":{"cacheId":"cache-a","url":"https://cache-a.example/cache/path"}}}}}, + {"id":"bad-a","price":2.0} + ]}], + "ext": {"responsetimemillis":{"suppress-exact":4},"errors":{"other":["fictional"]},"warnings":{"other":["warning"]},"debug":{"httpcalls":[]},"prebid":{"bidstatus":{"suppress-exact":[{"bidid":"good-a"}]}}} + })) + .expect("should serialize PBS response a"), + ); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "keep-seat", "bid": [{ + "id":"good-b","impid":"fictional-slot","price":2.5,"adm":"
b
","w":300,"h":250,"nurl":"https://notify.example/win","burl":"https://notify.example/bill" + }]}] + })) + .expect("should serialize PBS response b"), + ); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let notifications = NotificationConfig { + suppress_all: false, + suppress_seats: vec!["suppress-exact".to_string()], + }; + let plan = AuctionPlan::compile(planned_prebid_config(&[ + ( + "pbs-a", + serde_json::json!({"debug":true,"test_mode":true,"consent_forwarding":"openrtb_only"}), + notifications, + ), + ("pbs-b", serde_json::json!({}), NotificationConfig::default()), + ])) + .expect("should compile planned PBS auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::builder() + .uri("https://publisher.example/auction") + .header( + http::header::COOKIE, + "consent=keep; euconsent-v2=drop; other=value", + ) + .header(http::header::USER_AGENT, "Fictional Browser/7") + .header(http::header::REFERER, "https://referrer.example/story") + .header(http::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header("x-forwarded-for", "203.0.113.250") + .body(edgezero_core::body::Body::empty()) + .expect("should build inbound request"); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute planned PBS auction"); + + assert_eq!(result.provider_responses.len(), 2); + let first = &result.provider_responses[0]; + assert_eq!(first.provider, "pbs-a"); + assert_eq!(first.bids.len(), 1, "should isolate malformed sibling"); + assert_eq!( + first.bids[0].returned_seat.as_deref(), + Some("suppress-exact") + ); + assert_eq!(first.bids[0].bidder, "suppress-exact"); + assert!( + first.bids[0].nurl.is_none(), + "should suppress after normalization" + ); + assert!( + first.bids[0].burl.is_none(), + "should suppress billing notification" + ); + assert_eq!(first.bids[0].cache_id.as_deref(), Some("cache-a")); + assert_eq!(first.bids[0].cache_host.as_deref(), Some("cache-a.example")); + assert_eq!(first.bids[0].cache_path.as_deref(), Some("/cache/path")); + assert_eq!(first.metadata["responsetimemillis"]["suppress-exact"], 4); + assert!(first.metadata.contains_key("errors")); + assert!(first.metadata.contains_key("warnings")); + assert!(first.metadata.contains_key("debug")); + assert!(first.metadata.contains_key("bidstatus")); + let second = &result.provider_responses[1]; + assert_eq!(second.provider, "pbs-b"); + assert_eq!(second.bids[0].returned_seat.as_deref(), Some("keep-seat")); + assert!(second.bids[0].nurl.is_some()); + assert!(!second.metadata.contains_key("debug")); + assert!(!second.metadata.contains_key("bidstatus")); + + let headers = http.recorded_request_headers(); + assert_eq!(headers.len(), 2); + for request_headers in &headers { + assert!( + request_headers + .iter() + .any(|(name, value)| name == "user-agent" && value == "Fictional Browser/7") + ); + assert!(request_headers.iter().any( + |(name, value)| name == "referer" && value == "https://referrer.example/story" + )); + assert!( + request_headers + .iter() + .any(|(name, value)| name == "accept-language" && value == "en-US,en;q=0.9") + ); + assert!( + request_headers + .iter() + .all(|(name, _)| name != "x-forwarded-for"), + "must ignore inbound XFF without attestation" + ); + assert!( + request_headers.iter().all(|(name, _)| name != "accept"), + "planned PBS transport must not add Accept beyond legacy headers" + ); + } + let first_cookie = headers[0] + .iter() + .find(|(name, _)| name == "cookie") + .map(|(_, value)| value.as_str()); + assert_eq!(first_cookie, Some("consent=keep; other=value")); + let second_cookie = headers[1] + .iter() + .find(|(name, _)| name == "cookie") + .map(|(_, value)| value.as_str()); + assert_eq!( + second_cookie, + Some("consent=keep; euconsent-v2=drop; other=value") + ); + } + + #[tokio::test] + async fn planned_aps_mock_mediation_preserves_three_identities_and_renderer() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "upstream-seat", "bid": [{ + "id": "aps-bid", "impid": "fictional-slot", "price": 2.0, + "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"} + }]}] + })) + .expect("should serialize APS response"), + ); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "aps-instance", "bid": [{ + "id": "mediated-aps", "impid": "fictional-slot", "price": 2.0, + "adm": "ignored", "w": 300, "h": 250, "crid": "aps-creative" + }]}] + })) + .expect("should serialize mediator response"), + ); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = + AuctionPlan::compile(planned_aps_config()).expect("should compile planned APS auction"); + let mediator = AdServerMockProvider::new(AdServerMockConfig { + enabled: true, + endpoint: "https://mediator.example/mediate".to_string(), + timeout_ms: 500, + ..AdServerMockConfig::default() + }); + let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(mediator))); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should mediate planned APS bid"); + + let provider_bid = &result.provider_responses[0].bids[0]; + assert_eq!(result.provider_responses[0].provider, "aps-instance"); + assert_eq!(provider_bid.returned_seat.as_deref(), Some("upstream-seat")); + assert_eq!(provider_bid.bidder, "aps"); + let winner = &result.winning_bids["fictional-slot"]; + assert_eq!(winner.returned_seat.as_deref(), Some("upstream-seat")); + assert_eq!(winner.bidder, "aps"); + assert!(winner.renderer.is_some()); + assert!(winner.creative.is_none()); + assert_eq!( + result + .mediator_response + .as_ref() + .map(|response| response.provider.as_str()), + Some("adserver_mock") + ); + } + + #[tokio::test] + async fn planned_aps_transport_omits_accept_header() { + let http = Arc::new(StubHttpClient::new()); + http.push_response(400, Vec::new()); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = + AuctionPlan::compile(planned_aps_config()).expect("should compile planned APS auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::builder() + .uri("https://publisher.example/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build inbound request"); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should execute planned APS auction"); + + let headers = http.recorded_request_headers(); + assert_eq!(headers.len(), 1); + assert!( + headers[0].iter().all(|(name, _)| name != "accept"), + "planned APS transport must not add Accept beyond legacy headers" + ); + } + + #[tokio::test] + async fn planned_aps_profile_normalizes_renderer_reduction_and_metadata() { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers( + 200, + serde_json::to_vec(&serde_json::json!({ + "cur": "USD", + "seatbid": [ + {"seat": "returned-seat", "bid": [ + {"id": "z-high", "impid": "fictional-slot", "price": 2.0, "w": 300, "h": 250, + "nurl": "https://notice.example/win", "burl": "https://notice.example/bill", + "crid": "fictional-creative", "adomain": ["advertiser.example"], + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, + {"id": "a-high", "impid": "fictional-slot", "price": 2.0, "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, + {"id": "bad-script", "impid": "fictional-slot", "price": 9.0, "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "script"}}, + {"id": "bad-domain", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, + "ext": {"creativeurl": "https://publisher.example/render", "tagtype": "iframe"}}, + {"id": "bad-credentials", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, + "ext": {"creativeurl": "https://user:password@creative.example/render", "tagtype": "iframe"}}, + {"id": "bad-imp", "impid": "unknown-slot", "price": 8.0, "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, + {"id": "bad-dimensions", "impid": "fictional-slot", "price": 8.0, "w": 320, "h": 50, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, + {"id": "bad-price", "impid": "fictional-slot", "price": "high", "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, + {"id": "bad-mtype", "impid": "fictional-slot", "price": 8.0, "mtype": 2, "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, + {"id": "bad-tag", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "native"}}, + {"id": "bad-crid", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, + "crid": "x".repeat(1025), + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, + {"impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}} + ]}, + {"seat": 7, "bid": "bad-shape"} + ] + })) + .expect("should serialize APS profile response"), + vec![ + ("content-type", "application/json"), + ("authorization", "fictional-secret"), + ], + ); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_aps_instances_config(&[( + "aps-instance", + serde_json::json!({"account_id": "example-account", "debug": true}), + NotificationConfig { + suppress_all: false, + suppress_seats: vec!["returned-seat".to_string()], + }, + )])) + .expect("should compile planned APS profile"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute planned APS profile"); + + let response = &result.provider_responses[0]; + assert_eq!(response.provider, "aps-instance"); + assert_eq!(response.status, BidStatus::Success); + assert_eq!( + response.bids.len(), + 1, + "should retain one bid per impression" + ); + let bid = &response.bids[0]; + assert_eq!(bid.bidder, "aps"); + assert_eq!(bid.returned_seat.as_deref(), Some("returned-seat")); + assert_eq!( + bid.bid_id.as_deref(), + Some("a-high"), + "lexical ID should break equal-price tie" + ); + assert!(bid.creative.is_none()); + assert!( + bid.nurl.is_none() && bid.burl.is_none(), + "APS must discard notification URLs" + ); + let renderer = bid + .renderer + .as_ref() + .and_then(BidRenderer::as_aps) + .expect("should construct typed APS renderer"); + assert_eq!(renderer.account_id, "example-account"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&renderer.aax_response) + .expect("should decode minimized APS response"); + assert_eq!( + serde_json::from_slice::(&decoded) + .expect("should parse minimized APS response"), + serde_json::json!({"seatbid":[{"bid":[{ + "id":"a-high","price":2.0,"w":300,"h":250, + "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} + }]}]}) + ); + assert_eq!(response.metadata["seatbid_count"], 2); + assert_eq!(response.metadata["accepted_bid_count"], 1); + assert_eq!(response.metadata["dropped_bid_count"], 12); + for reason in [ + "lost_to_higher_bid", + "script_rendering_disabled", + "unknown_impid", + "invalid_dimensions", + "invalid_price", + "unsupported_media_type", + "unsupported_tagtype", + "creative_id_too_large", + "missing_render_source", + "empty_seatbid_bids", + ] { + assert_eq!(response.metadata["drop_reasons"][reason], 1, "{reason}"); + } + assert_eq!( + response.metadata["drop_reasons"]["invalid_creative_url"], 2, + "same-publisher and credentialed URLs should both be rejected" + ); + assert_eq!( + response.metadata["routing"]["unused_bidder_params_count"], + 0 + ); + let debug = &response.metadata["debug"]["httpcalls"]["aps"][0]; + assert_eq!(debug["uri"], "https://aps.example/e/pb/bid"); + assert_eq!( + debug["responseheaders"], + serde_json::json!({}), + "async stub does not preserve queued response headers" + ); + assert!( + debug["requestbody"] + .as_str() + .is_some_and(|body| body.contains("example-account")) + ); + assert!(debug["requestheaders"].get("authorization").is_none()); + assert!(debug["responseheaders"].get("authorization").is_none()); + } + + #[tokio::test] + async fn two_planned_aps_instances_correlate_independently() { + let http = Arc::new(StubHttpClient::new()); + for (seat, id, price) in [("seat-a", "bid-a", 1.0), ("seat-b", "bid-b", 2.0)] { + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({"seatbid":[{"seat":seat,"bid":[{ + "id":id,"impid":"fictional-slot","price":price,"w":300,"h":250, + "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} + }]}]})) + .expect("should serialize APS instance response"), + ); + } + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_aps_instances_config(&[ + ( + "aps-a", + serde_json::json!({"account_id":"account-a"}), + NotificationConfig::default(), + ), + ( + "aps-b", + serde_json::json!({"account_id":"account-b"}), + NotificationConfig::default(), + ), + ])) + .expect("should compile two APS instances"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute two APS instances"); + + assert_eq!(result.provider_responses.len(), 2); + assert_eq!(result.provider_responses[0].provider, "aps-a"); + assert_eq!( + result.provider_responses[0].bids[0].bid_id.as_deref(), + Some("bid-a") + ); + assert_eq!(result.provider_responses[1].provider, "aps-b"); + assert_eq!( + result.provider_responses[1].bids[0].bid_id.as_deref(), + Some("bid-b") + ); + assert_eq!(http.recorded_request_bodies().len(), 2); + assert_eq!( + result.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("bid-b"), + "global ranking should remain orchestrator-owned" + ); + let specs = backend.specs.lock().expect("should lock specs"); + assert_eq!(specs.len(), 2); + assert_ne!(specs[0].discriminator, specs[1].discriminator); + } + + #[tokio::test] + async fn planned_aps_returned_seat_accepts_only_valid_nonempty_strings() { + let plan = AuctionPlan::compile(planned_aps_config()).expect("should compile APS plan"); + let routed = route_auction( + planned_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + &plan, + None, + ); + let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + for (seat, expected) in [ + (serde_json::Value::Null, None), + (serde_json::json!(7), None), + (serde_json::json!(""), None), + (serde_json::json!("exact-seat"), Some("exact-seat")), + ] { + let state = provider.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(200) + .body(edgezero_core::body::Body::from( + serde_json::to_vec(&serde_json::json!({"seatbid":[{"seat":seat,"bid":[{ + "id":"bid","impid":"fictional-slot","price":1.0,"w":300,"h":250, + "nurl":"https://notice.example/win","burl":"https://notice.example/bill", + "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} + }]}]})) + .expect("should serialize seat identity response"), + )) + .expect("should build seat identity response"), + ); + let parsed = provider + .parse_response_with_state(response, 4, Some(state.as_ref())) + .await + .expect("should parse seat identity response"); + assert_eq!(parsed.bids[0].returned_seat.as_deref(), expected); + assert!(parsed.bids[0].nurl.is_none() && parsed.bids[0].burl.is_none()); + } + } + + #[tokio::test] + async fn planned_aps_response_status_shape_and_currency_matrix() { + let plan = AuctionPlan::compile(planned_aps_config()).expect("should compile APS plan"); + let routed = route_auction( + planned_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + &plan, + None, + ); + let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let cases = [ + (204, Vec::new(), BidStatus::NoBid, None), + (400, Vec::new(), BidStatus::Error, None), + ( + 200, + b"not-json".to_vec(), + BidStatus::Error, + Some("unexpected_response_shape"), + ), + ( + 200, + b"[]".to_vec(), + BidStatus::Error, + Some("unexpected_response_shape"), + ), + ( + 200, + br#"{"contextual":true}"#.to_vec(), + BidStatus::Error, + Some("unexpected_response_shape"), + ), + ( + 200, + br#"{"cur":"EUR","seatbid":[]}"#.to_vec(), + BidStatus::NoBid, + Some("unsupported_currency"), + ), + ]; + for (status, body, expected, reason) in cases { + let state = provider.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(status) + .body(edgezero_core::body::Body::from(body)) + .expect("should build APS matrix response"), + ); + let parsed = provider + .parse_response_with_state(response, 4, Some(state.as_ref())) + .await + .expect("should classify APS matrix response"); + assert_eq!(parsed.status, expected, "status {status}"); + if let Some(reason) = reason { + assert_eq!( + parsed.metadata["drop_reasons"][reason], 1, + "status {status}" + ); + } + } + } + + #[tokio::test] + async fn planned_provider_outcome_matrix_has_fixed_count_only_routing_metadata() { + let standard_plan = AuctionPlan::compile(planned_config( + &[("standard", RoutingMode::AllEligible)], + false, + )) + .expect("should compile standard plan"); + let prebid_plan = AuctionPlan::compile(planned_prebid_config(&[( + "pbs", + serde_json::json!({}), + NotificationConfig::default(), + )])) + .expect("should compile PBS plan"); + let cases = [ + (&standard_plan, 204, Vec::new(), BidStatus::NoBid), + (&standard_plan, 502, Vec::new(), BidStatus::Error), + (&standard_plan, 200, b"not-json".to_vec(), BidStatus::Error), + ( + &standard_plan, + 200, + br#"{"seatbid":[]}"#.to_vec(), + BidStatus::NoBid, + ), + (&prebid_plan, 204, b"{}".to_vec(), BidStatus::NoBid), + (&prebid_plan, 502, Vec::new(), BidStatus::Error), + (&prebid_plan, 200, b"not-json".to_vec(), BidStatus::Error), + ( + &prebid_plan, + 200, + br#"{"seatbid":[]}"#.to_vec(), + BidStatus::NoBid, + ), + ]; + + for (plan, status, body, expected) in cases { + let routed = route_auction( + planned_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + plan, + None, + ); + let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let state = provider.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(status) + .body(edgezero_core::body::Body::from(body)) + .expect("should build provider matrix response"), + ); + let parsed = provider + .parse_response_with_state(response, 4, Some(state.as_ref())) + .await + .expect("should classify provider matrix response"); + assert_eq!(parsed.status, expected, "status {status}"); + assert_eq!( + parsed.metadata["routing"], + serde_json::json!({"unused_bidder_params_count": 0}) + ); + let serialized = serde_json::to_string(&parsed.metadata["routing"]) + .expect("should serialize routing metadata"); + assert!(!serialized.contains("fictional-provider")); + assert!(!serialized.contains("fictional-slot")); + } + } + + #[tokio::test] + async fn planned_aps_script_opt_in_matches_shared_renderer_fixture() { + let plan = AuctionPlan::compile(planned_aps_instances_config(&[( + "aps-instance", + serde_json::json!({ + "account_id":"example-account-id", + "allow_script_creatives":true + }), + NotificationConfig::default(), + )])) + .expect("should compile script-enabled APS plan"); + let routed = route_auction( + planned_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + &plan, + None, + ); + let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let state = provider.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(200) + .body(edgezero_core::body::Body::from( + serde_json::to_vec(&serde_json::json!({"seatbid":[{"bid":[{ + "id":"fictional-selected-bid-id","impid":"fictional-slot","price":1.23, + "w":300,"h":250,"crid":"fictional-creative", + "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} + },{ + "id":"script-bid","impid":"fictional-slot","price":1.0, + "w":300,"h":250, + "ext":{"creativeurl":"https://creative.example/script","tagtype":"script"} + }]}]})) + .expect("should serialize APS renderer fixture response"), + )) + .expect("should build APS renderer fixture response"), + ); + + let parsed = provider + .parse_response_with_state(response, 3, Some(state.as_ref())) + .await + .expect("should parse APS renderer fixture response"); + + assert_eq!(parsed.status, BidStatus::Success); + assert_eq!( + parsed.metadata["drop_reasons"]["lost_to_higher_bid"], 1, + "enabled script creative should be eligible before reduction" + ); + let renderer = parsed.bids[0] + .renderer + .as_ref() + .and_then(BidRenderer::as_aps) + .expect("should construct APS renderer"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&renderer.aax_response) + .expect("should decode APS fixture envelope"); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" + )) + .expect("should parse shared APS renderer fixture"); + assert_eq!( + serde_json::from_slice::(&decoded) + .expect("should parse decoded APS renderer"), + fixture + ); + } + + #[tokio::test] + async fn planned_aps_debug_response_headers_are_allowlisted() { + let plan = AuctionPlan::compile(planned_aps_instances_config(&[( + "aps-instance", + serde_json::json!({"account_id":"example-account","debug":true}), + NotificationConfig::default(), + )])) + .expect("should compile debug APS plan"); + let routed = route_auction( + planned_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + &plan, + None, + ); + let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let state = provider.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(200) + .header("content-type", "application/json") + .header("authorization", "fictional-secret") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build debug APS response"), + ); + + let parsed = provider + .parse_response_with_state(response, 3, Some(state.as_ref())) + .await + .expect("should parse debug APS response"); + + let headers = &parsed.metadata["debug"]["httpcalls"]["aps"][0]["responseheaders"]; + assert_eq!( + headers, + &serde_json::json!({"content-type":["application/json"]}) + ); + assert!(headers.get("authorization").is_none()); + } + + #[tokio::test] + async fn planned_standard_instances_have_distinct_backends_and_independent_results() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "fictional-seat-a", "bid": [{ + "id": "bid-a", "impid": "fictional-slot", "price": 1.25, + "adm": "
a
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize response a"), + ); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "fictional-seat-b", "bid": [{ + "id": "bid-b", "impid": "fictional-slot", "price": 2.5, + "adm": "
b
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize response b"), + ); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider-a", RoutingMode::AllEligible), + ("provider-b", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::builder() + .uri("https://publisher.example/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build inbound request"); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute planned auction"); + + assert_eq!(orchestrator.provider_count(), 2); + assert!(orchestrator.mediator().is_none()); + assert_eq!(result.provider_responses.len(), 2); + assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!( + result.provider_responses[0].bids[0].bid_id.as_deref(), + Some("bid-a") + ); + assert_eq!( + result.provider_responses[0].bids[0] + .returned_seat + .as_deref(), + Some("fictional-seat-a") + ); + assert_eq!( + result.provider_responses[0].metadata["routing"]["unused_bidder_params_count"], + 0 + ); + assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!( + result.provider_responses[1].bids[0].bid_id.as_deref(), + Some("bid-b") + ); + assert_eq!( + result.provider_responses[1].bids[0] + .returned_seat + .as_deref(), + Some("fictional-seat-b") + ); + assert_eq!( + result.provider_responses[1].metadata["routing"]["unused_bidder_params_count"], + 0 + ); + assert_eq!( + result.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("bid-b") + ); + let request_headers = http.recorded_request_headers(); + assert_eq!(request_headers.len(), 2); + for headers in request_headers { + assert!( + headers + .iter() + .any(|(name, value)| name == "accept" && value == "application/json"), + "standard planned transport should retain its JSON Accept header" + ); + } + let backend_names = http.recorded_backend_names(); + assert_eq!(backend_names.len(), 2); + assert_ne!(backend_names[0], backend_names[1]); + let request_bodies = http.recorded_request_bodies(); + assert_eq!(request_bodies.len(), 2); + for body in request_bodies { + let value: serde_json::Value = + serde_json::from_slice(&body).expect("should parse planned request"); + let tmax = value["tmax"].as_u64().expect("should include logical tmax"); + assert!( + (750..=777).contains(&tmax), + "logical budget should remain near the auction budget, got {tmax}" + ); + assert_eq!(value["imp"].as_array().map(Vec::len), Some(1)); + } + let specs = backend.specs.lock().expect("should lock specs"); + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].first_byte_timeout, Duration::from_millis(750)); + assert_eq!(specs[1].first_byte_timeout, Duration::from_millis(750)); + assert_ne!(specs[0].discriminator, specs[1].discriminator); + } + + #[tokio::test] + async fn planned_backend_collision_does_not_overwrite_first_launch_state() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "first", "bid": [{ + "id": "first-bid", "impid": "fictional-slot", "price": 2.0, + "adm": "
first
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize first response"), + ); + let backend = Arc::new(CollidingBackend); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider-a", RoutingMode::AllEligible), + ("provider-b", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should isolate backend collision"); + + assert_eq!(http.recorded_backend_names().len(), 1); + assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!( + result.provider_responses[0].bids[0].bid_id.as_deref(), + Some("first-bid") + ); + assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!( + result.provider_responses[1].metadata["error_type"], + "launch_failed" + ); + } + + #[tokio::test] + async fn planned_pending_backend_divergence_isolated_from_valid_provider() { + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + http.push_response(204, Vec::new()); + http.push_pending_backend_name_override(Some("divergent-backend")); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider-a", RoutingMode::AllEligible), + ("provider-b", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should isolate divergent pending backend"); + + assert_eq!(result.provider_responses.len(), 2); + assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!( + result.provider_responses[0].metadata["error_type"], + "launch_failed" + ); + assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!(result.provider_responses[1].status, BidStatus::NoBid); + } + + #[tokio::test] + async fn planned_pending_backend_missing_isolated_from_valid_provider() { + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + http.push_response(204, Vec::new()); + http.push_pending_backend_name_override(None); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider-a", RoutingMode::AllEligible), + ("provider-b", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should isolate missing pending backend"); + + assert_eq!(result.provider_responses.len(), 2); + assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!( + result.provider_responses[0].metadata["error_type"], + "launch_failed" + ); + assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!(result.provider_responses[1].status, BidStatus::NoBid); + } + + #[tokio::test] + async fn planned_same_profile_rejects_cross_provider_parse_state() { + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider-a", RoutingMode::AllEligible), + ("provider-b", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"); + let routed = route_auction( + planned_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + &plan, + None, + ); + let provider_a = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let provider_b = GenericOpenRtbProvider::new(plan.providers()[1].clone()); + let parse_state = provider_a.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(204) + .body(edgezero_core::body::Body::empty()) + .expect("should build no-content response"), + ); + + let error = provider_b + .parse_response_with_state(response, 1, Some(parse_state.as_ref())) + .await + .expect_err("should reject another provider's parse state"); + + assert!( + error.to_string().contains("owned by provider provider-a"), + "should identify cross-provider state ownership" + ); + } + + #[tokio::test] + async fn planned_prebid_rejects_cross_provider_parse_state() { + let plan = AuctionPlan::compile(planned_prebid_config(&[ + ( + "pbs-a", + serde_json::json!({}), + NotificationConfig::default(), + ), + ( + "pbs-b", + serde_json::json!({}), + NotificationConfig::default(), + ), + ])) + .expect("should compile planned PBS auction"); + let routed = route_auction( + planned_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + &plan, + None, + ); + let provider_a = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let provider_b = GenericOpenRtbProvider::new(plan.providers()[1].clone()); + let parse_state = provider_a.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(200) + .body(edgezero_core::body::Body::from_bytes(b"{}".as_slice())) + .expect("should build PBS response"), + ); + + let error = provider_b + .parse_response_with_state(response, 1, Some(parse_state.as_ref())) + .await + .expect_err("should reject another PBS provider's parse state"); + + assert!( + error.to_string().contains("owned by provider pbs-a"), + "should identify cross-provider PBS state ownership" + ); + } + + #[tokio::test] + async fn planned_skipped_provider_has_no_io_and_no_zero_impression_launch() { + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[ + ("eligible", RoutingMode::AllEligible), + ("skipped", RoutingMode::Explicit), + ], + false, + )) + .expect("should compile planned auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute eligible provider only"); + + assert_eq!(http.recorded_backend_names().len(), 1); + assert_eq!(backend.ensured.load(Ordering::Relaxed), 1); + let skipped = result + .provider_responses + .iter() + .find(|response| response.provider == "skipped") + .expect("should materialize skipped provider"); + assert_eq!(skipped.status, BidStatus::NoBid); + assert_eq!( + skipped.metadata["routing"]["skipped_no_eligible_slots"], + true + ); + let request_body = &http.recorded_request_bodies()[0]; + let request_value: serde_json::Value = + serde_json::from_slice(request_body).expect("should parse request body"); + assert_eq!(request_value["imp"].as_array().map(Vec::len), Some(1)); + } + + #[tokio::test] + async fn planned_launch_transport_parse_failures_are_isolated_from_valid_winner_and_floor() { + let http = Arc::new(StubHttpClient::new()); + // BTreeMap plan order is alphabetical: below-floor, parse-fail, + // transport-fail, valid-winner. Queue responses in that exact order. + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "below-floor", "bid": [{ + "id": "below", "impid": "fictional-slot", "price": 0.5, + "adm": "
below
", "w": 300, "h": 250 + }, { + "id": "below-only", "impid": "below-only-slot", "price": 0.5, + "adm": "
below only
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize below-floor response"), + ); + http.push_response(200, b"not-json".to_vec()); + http.push_response(200, b"{}".to_vec()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "winner", "bid": [{ + "id": "winner", "impid": "fictional-slot", "price": 2.0, + "adm": "
winner
", "w": 300, "h": 250 + }]}] + })) + .expect("should serialize winner response"), + ); + http.push_select_success(); + http.push_select_success(); + http.push_select_error(); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + backend.fail_ensure_for("launch-fail"); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[ + ("launch-fail", RoutingMode::AllEligible), + ("transport-fail", RoutingMode::AllEligible), + ("parse-fail", RoutingMode::AllEligible), + ("below-floor", RoutingMode::AllEligible), + ("valid-winner", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile failure isolation plan"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let mut request = planned_request(); + let mut below_only_slot = request.slots[0].clone(); + below_only_slot.id = "below-only-slot".to_string(); + request.slots.push(below_only_slot); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should isolate planned provider failures"); + + let by_provider = result + .provider_responses + .iter() + .map(|response| (response.provider.as_str(), response)) + .collect::>(); + assert_eq!( + by_provider + .get("launch-fail") + .unwrap_or_else(|| panic!( + "should include launch-fail response; got {:?}", + by_provider.keys().collect::>() + )) + .metadata["error_type"], + "launch_failed" + ); + let below_floor = by_provider.get("below-floor").unwrap_or_else(|| { + panic!( + "should include below-floor response; got {:?}", + by_provider.keys().collect::>() + ) + }); + assert_eq!(below_floor.status, BidStatus::Success); + assert_eq!(below_floor.bids[0].bid_id.as_deref(), Some("below")); + assert_eq!(below_floor.bids[0].price, Some(0.5)); + assert_eq!(below_floor.bids[1].bid_id.as_deref(), Some("below-only")); + assert_eq!( + by_provider["transport-fail"].metadata["error_type"], + "transport" + ); + let parse_failure = by_provider.get("parse-fail").unwrap_or_else(|| { + panic!( + "should include parse-fail response; got {:?}", + by_provider.keys().collect::>() + ) + }); + assert_eq!(parse_failure.status, BidStatus::Error); + assert_eq!( + parse_failure.metadata["routing"]["unused_bidder_params_count"], + 0 + ); + for response in by_provider.values() { + assert_eq!( + response.metadata["routing"]["unused_bidder_params_count"], 0, + "every materialized planned provider response should have routing count" + ); + } + assert_eq!(by_provider["valid-winner"].status, BidStatus::Success); + assert_eq!( + result.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("winner") + ); + assert!( + !result.winning_bids.contains_key("below-only-slot"), + "valid below-floor bid should be discarded when it is the only candidate" + ); + } + + #[tokio::test] + async fn planned_routing_count_survives_standard_and_aps_bounded_body_failures() { + for (profile, provider_id) in [("standard", "standard"), ("aps", "aps-instance")] { + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, vec![b'x'; 1024 * 1024 + 1]); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let mut config = if profile == "standard" { + planned_config(&[(provider_id, RoutingMode::AllEligible)], false) + } else { + planned_aps_config() + }; + config.bidders.insert( + "example-bidder" + .parse() + .expect("should parse fictional bidder ID"), + crate::auction::plan::BidderRouteConfig { + provider: provider_id + .parse() + .expect("should parse fictional provider ID"), + }, + ); + let plan = AuctionPlan::compile(config).expect("should compile bounded-body plan"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let mut request = planned_request(); + request.slots[0].bidders.insert( + "example-bidder".to_string(), + serde_json::json!({"private": "value"}), + ); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should materialize bounded-body failure"); + let response = &result.provider_responses[0]; + assert_eq!(response.status, BidStatus::Error, "{profile}"); + assert_eq!( + response.metadata["routing"]["unused_bidder_params_count"], 1, + "{profile} bounded-body failure should retain the input-derived count" + ); + let routing = serde_json::to_string(&response.metadata["routing"]) + .expect("should serialize routing metadata"); + assert!(!routing.contains("example-bidder") && !routing.contains("private")); + } + } + + #[tokio::test] + async fn planned_signer_admission_time_reduces_budget_and_total_time_includes_it() { + let config_store = Arc::new(CountingConfigStore { + reads: AtomicUsize::new(0), + current_kid: "test-kid".to_string(), + delay: Duration::from_millis(50), + }); + let secret_store = Arc::new(CountingSecretStore { + reads: AtomicUsize::new(0), + key: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [7_u8; 32]) + .into_bytes(), + }); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + let services = RuntimeServices::builder() + .config_store(Arc::clone(&config_store) as Arc<_>) + .secret_store(Arc::clone(&secret_store) as Arc<_>) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::clone(&backend) as Arc<_>) + .http_client(Arc::clone(&http) as Arc<_>) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .auction_telemetry_sink(Arc::new( + crate::auction::telemetry::NoopAuctionTelemetrySink, + )) + .client_info(crate::platform::ClientInfo::default()) + .build(); + let plan = AuctionPlan::compile(planned_config( + &[("signed", RoutingMode::AllEligible)], + true, + )) + .expect("should compile signed plan"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 200, + transport_timeout_ms: 200, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute signed planned auction"); + + let body: serde_json::Value = serde_json::from_slice(&http.recorded_request_bodies()[0]) + .expect("should parse signed request"); + let tmax = body["tmax"].as_u64().expect("should include tmax"); + assert!( + (100..=175).contains(&tmax), + "signer delay should reduce logical budget, got {tmax}" + ); + assert!( + result.total_time_ms >= 50, + "total time should include signer admission" + ); + assert_eq!(config_store.reads.load(Ordering::Relaxed), 1); + assert_eq!(secret_store.reads.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn planned_signed_multi_provider_loads_signer_once_and_sends_twice() { + let config_store = Arc::new(CountingConfigStore { + reads: AtomicUsize::new(0), + current_kid: "test-kid".to_string(), + delay: Duration::ZERO, + }); + let secret_store = Arc::new(CountingSecretStore { + reads: AtomicUsize::new(0), + key: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [11_u8; 32]) + .into_bytes(), + }); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + http.push_response(204, Vec::new()); + let services = RuntimeServices::builder() + .config_store(Arc::clone(&config_store) as Arc<_>) + .secret_store(Arc::clone(&secret_store) as Arc<_>) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::clone(&backend) as Arc<_>) + .http_client(Arc::clone(&http) as Arc<_>) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .auction_telemetry_sink(Arc::new( + crate::auction::telemetry::NoopAuctionTelemetrySink, + )) + .client_info(crate::platform::ClientInfo::default()) + .build(); + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider-a", RoutingMode::AllEligible), + ("provider-b", RoutingMode::AllEligible), + ], + true, + )) + .expect("should compile signed plan"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should execute signed multi-provider auction"); + + assert_eq!(result.provider_responses.len(), 2); + assert_eq!(config_store.reads.load(Ordering::Relaxed), 1); + assert_eq!(secret_store.reads.load(Ordering::Relaxed), 1); + assert_eq!(http.recorded_backend_names().len(), 2); + for body in http.recorded_request_bodies() { + let value: serde_json::Value = + serde_json::from_slice(&body).expect("should parse signed provider request"); + assert!( + value["ext"]["trusted_server"]["signature"].is_string(), + "should sign every request" + ); + } + } + + #[tokio::test] + async fn from_plan_signing_failure_is_fatal_for_direct_and_safe_for_split() { + for split in [false, true] { + let config_store = Arc::new(FailingCountingConfigStore { + reads: AtomicUsize::new(0), + }); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let http = Arc::new(StubHttpClient::new()); + let services = RuntimeServices::builder() + .config_store(Arc::clone(&config_store) as Arc<_>) + .secret_store(Arc::new(UnusedSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::clone(&backend) as Arc<_>) + .http_client(Arc::clone(&http) as Arc<_>) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .auction_telemetry_sink(Arc::new( + crate::auction::telemetry::NoopAuctionTelemetrySink, + )) + .client_info(crate::platform::ClientInfo::default()) + .build(); + let mut config = planned_config(&[("signed", RoutingMode::AllEligible)], true); + config.bidders.insert( + "unknown-private-id" + .parse() + .expect("should parse fictional bidder ID"), + crate::auction::plan::BidderRouteConfig { + provider: "signed" + .parse() + .expect("should parse fictional provider ID"), + }, + ); + let plan = Arc::new(AuctionPlan::compile(config).expect("should compile signed plan")); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let mut request = planned_request(); + request.slots[0].bidders.insert( + "unroutable-private-id".to_string(), + serde_json::json!({"secret": 9}), + ); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + if split { + let DispatchAuctionOutcome::DispatchFailed { + provider_responses, + fatal_admission_error, + metadata, + .. + } = orchestrator.dispatch_auction(&request, &context).await + else { + panic!("split signer failure should be explicit"); + }; + let error = fatal_admission_error.expect("should carry fatal admission error"); + assert!(format!("{error:?}").contains("current-kid")); + assert_eq!(provider_responses.len(), 1); + assert_eq!( + provider_responses[0].metadata["routing"]["unused_bidder_params_count"], + 0 + ); + assert_eq!(metadata["routing"]["unroutable_bidder_count"], 1); + let serialized = + serde_json::to_string(&metadata).expect("should serialize routing metadata"); + assert!( + !serialized.contains("unroutable-private-id") && !serialized.contains("secret") + ); + } else { + let error = orchestrator + .run_auction(&request, &context) + .await + .expect_err("direct signer failure should propagate"); + let report = format!("{error:?}"); + assert!(report.contains("Planned auction admission failed")); + assert!(report.contains("current-kid")); + } + + assert_eq!(config_store.reads.load(Ordering::Relaxed), 1); + assert_eq!(backend.predicted.load(Ordering::Relaxed), 0); + assert_eq!(backend.ensured.load(Ordering::Relaxed), 0); + assert!(http.recorded_backend_names().is_empty()); + } + } + + #[tokio::test] + async fn planned_signing_failure_reads_store_once_before_backend_or_send() { + let config_store = Arc::new(FailingCountingConfigStore { + reads: AtomicUsize::new(0), + }); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let http = Arc::new(StubHttpClient::new()); + let services = RuntimeServices::builder() + .config_store(Arc::clone(&config_store) as Arc<_>) + .secret_store(Arc::new(UnusedSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::clone(&backend) as Arc<_>) + .http_client(Arc::clone(&http) as Arc<_>) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .auction_telemetry_sink(Arc::new( + crate::auction::telemetry::NoopAuctionTelemetrySink, + )) + .client_info(crate::platform::ClientInfo::default()) + .build(); + let plan = AuctionPlan::compile(planned_config( + &[("signed", RoutingMode::AllEligible)], + true, + )) + .expect("should compile signed plan"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let _error = orchestrator + .run_auction(&request, &context) + .await + .expect_err("should fail signer admission"); + + assert_eq!(config_store.reads.load(Ordering::Relaxed), 1); + assert_eq!(backend.predicted.load(Ordering::Relaxed), 0); + assert_eq!(backend.ensured.load(Ordering::Relaxed), 0); + assert!(http.recorded_backend_names().is_empty()); + } + + #[tokio::test] + async fn planned_zero_logical_budget_does_no_signer_backend_or_network_work() { + let config_store = Arc::new(FailingCountingConfigStore { + reads: AtomicUsize::new(0), + }); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let http = Arc::new(StubHttpClient::new()); + let services = RuntimeServices::builder() + .config_store(Arc::clone(&config_store) as Arc<_>) + .secret_store(Arc::new(UnusedSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::clone(&backend) as Arc<_>) + .http_client(Arc::clone(&http) as Arc<_>) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .auction_telemetry_sink(Arc::new( + crate::auction::telemetry::NoopAuctionTelemetrySink, + )) + .client_info(crate::platform::ClientInfo::default()) + .build(); + let plan = AuctionPlan::compile(planned_config( + &[("signed", RoutingMode::AllEligible)], + true, + )) + .expect("should compile signed plan"); + let mediator_launches = Arc::new(AtomicUsize::new(0)); + let orchestrator = AuctionOrchestratorHarness::new( + plan, + Some(Arc::new(DeadlineRecordingMediator { + launches: Arc::clone(&mediator_launches), + budgets: None, + })), + ); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 0, + transport_timeout_ms: 0, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should return zero-budget outcomes"); + + assert_eq!(result.provider_responses.len(), 1); + assert_eq!( + result.provider_responses[0].metadata["error_type"], + "timeout" + ); + assert_eq!(config_store.reads.load(Ordering::Relaxed), 0); + assert_eq!(backend.predicted.load(Ordering::Relaxed), 0); + assert_eq!(backend.ensured.load(Ordering::Relaxed), 0); + assert!(http.recorded_backend_names().is_empty()); + assert_eq!( + mediator_launches.load(Ordering::Relaxed), + 0, + "zero budget must not invoke even an immediate mediator" + ); + } + + #[tokio::test] + async fn planned_fanout_rejection_happens_before_backend_or_send() { + let http = Arc::new(StubHttpClient::new()); + http.set_concurrent_fanout(false); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Cloudflare)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider-a", RoutingMode::AllEligible), + ("provider-b", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let _error = orchestrator + .run_auction(&request, &context) + .await + .expect_err("should reject unsupported concurrent fanout"); + + assert_eq!(backend.predicted.load(Ordering::Relaxed), 0); + assert_eq!(backend.ensured.load(Ordering::Relaxed), 0); + assert!(http.recorded_backend_names().is_empty()); + } + + #[test] + fn routing_metadata_is_fixed_count_only_and_saturating() { + let metadata = super::routing_metadata( + RoutingDiagnostics::saturated_for_test().unroutable_bidder_count(), + ); + assert_eq!( + metadata, + HashMap::from([( + "routing".to_string(), + serde_json::json!({"unroutable_bidder_count": u32::MAX}), + )]) + ); + assert!( + !serde_json::to_string(&metadata) + .expect("should serialize routing metadata") + .contains("bidder_id"), + "routing metadata must not expose bidder identifiers" + ); + } + #[test] fn decoded_aps_bid_competes_directly_by_cpm() { let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); @@ -3294,6 +7191,7 @@ mod tests { creative: Some("
Ad
".to_string()), adomain: None, bidder: "aps".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -3338,6 +7236,7 @@ mod tests { creative: Some("
APS Ad
".to_string()), adomain: None, bidder: "aps".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -3377,6 +7276,7 @@ mod tests { creative: Some("
APS Ad
".to_string()), adomain: None, bidder: "aps".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs new file mode 100644 index 000000000..8f5224c80 --- /dev/null +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -0,0 +1,1173 @@ +//! Target-independent config-first auction plan compiler. + +use std::collections::{BTreeMap, BTreeSet}; +use std::str::FromStr; +use std::time::Duration; + +use error_stack::{Report, ResultExt as _}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use url::Url; + +use super::profile::{CompiledOpenRtbProfile, ProfileTimeoutDefault, find_profile}; +use crate::error::TrustedServerError; +use crate::platform::{AuctionTargetId, PlatformBackendSpec}; +use crate::settings::RequestSigning; + +const MAX_ID_BYTES: usize = 128; +const MAX_SUPPRESS_SEATS: usize = 128; +const MAX_SUPPRESS_SEAT_BYTES: usize = 128; +const MOCK_MEDIATOR_ID: &str = "adserver_mock"; +const RESERVED_BROWSER_ENVELOPE_BIDDER_ID: &str = "trustedServer"; + +/// Validated operator-defined provider identifier. +#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd, derive_more::Display)] +pub struct ProviderId(String); + +impl ProviderId { + /// Borrow the validated identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[cfg(test)] + pub(crate) fn unchecked_for_legacy_test(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl FromStr for ProviderId { + type Err = Report; + + fn from_str(value: &str) -> Result { + let valid = !value.is_empty() + && value.len() <= 63 + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && value + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-'); + if !valid { + return Err(configuration_error(format!( + "provider ID `{value}` must match ^[a-z][a-z0-9-]{{0,62}}$" + ))); + } + Ok(Self(value.to_string())) + } +} + +impl<'de> Deserialize<'de> for ProviderId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::from_str(&value).map_err(serde::de::Error::custom) + } +} + +impl Serialize for ProviderId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +/// Validated client-visible bidder identifier. +#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd, derive_more::Display)] +pub struct BidderId(String); + +impl BidderId { + /// Borrow the validated identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for BidderId { + type Err = Report; + + fn from_str(value: &str) -> Result { + if value.is_empty() + || value.len() > MAX_ID_BYTES + || value.chars().any(char::is_control) + || value.trim() != value + { + return Err(configuration_error(format!( + "bidder ID must be nonempty, at most {MAX_ID_BYTES} UTF-8 bytes, contain no control characters, and have no surrounding whitespace" + ))); + } + Ok(Self(value.to_string())) + } +} + +impl<'de> Deserialize<'de> for BidderId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::from_str(&value).map_err(serde::de::Error::custom) + } +} + +impl Serialize for BidderId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +/// Raw config-first provider declaration. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderConfig { + /// Protocol identifier. Version one accepts only `openrtb-2.6`. + pub protocol: String, + /// Registered profile identifier. + #[serde(default = "default_profile")] + pub profile: String, + /// Fixed provider endpoint. + pub endpoint: String, + /// Optional profile-default timeout override. + #[serde(default)] + pub timeout_ms: Option, + /// Slot routing mode. + #[serde(default)] + pub routing: RoutingMode, + /// Common `OpenRTB` notification policy. + #[serde(default)] + pub notifications: NotificationConfig, + /// Selected profile's typed configuration object. + #[serde(default = "empty_object")] + pub profile_config: Value, +} + +/// Raw central bidder route. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BidderRouteConfig { + /// Referenced provider identifier. + pub provider: ProviderId, +} + +/// Provider slot routing behavior. +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutingMode { + /// Route only centrally assigned or trusted demand. + #[default] + Explicit, + /// Route every banner-compatible slot. + AllEligible, +} + +/// Common normalized-notification suppression configuration. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct NotificationConfig { + /// Suppress notification URLs for every normalized bid. + #[serde(default)] + pub suppress_all: bool, + /// Suppress notification URLs for exact returned-seat matches. + #[serde(default)] + pub suppress_seats: Vec, +} + +/// Raw internal input for target-independent plan compilation. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AuctionPlanConfig { + /// Auction-wide logical timeout. + pub timeout_ms: u32, + /// Operator-defined provider instances. + #[serde(default)] + pub providers: BTreeMap, + /// Client bidder-to-provider routes. + #[serde(default)] + pub bidders: BTreeMap, + /// Existing separately registered mock mediator. + #[serde(default)] + pub mediator: Option, + /// Existing global Trusted Server signing configuration. + #[serde(default)] + pub request_signing: Option, +} + +/// Canonical absolute provider endpoint. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct CanonicalProviderEndpoint(Url); + +impl CanonicalProviderEndpoint { + /// Borrow the canonical endpoint string. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub(crate) fn url(&self) -> &Url { + &self.0 + } +} + +/// Closed first-version protocol plan. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ProtocolPlan { + /// `OpenRTB` version 2.6 subset. + OpenRtb26, +} + +/// Immutable common notification policy. +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct NotificationPolicy { + /// Suppress notification URLs for every bid. + pub suppress_all: bool, + /// Exact returned seats whose notification URLs are suppressed. + pub suppress_seats: BTreeSet, +} + +/// Immutable compiled provider instance. +#[derive(Debug, Clone)] +pub struct ProviderPlan { + /// Provider identity. + pub id: ProviderId, + /// Canonical endpoint. + pub endpoint: CanonicalProviderEndpoint, + /// Resolved profile-default or explicit timeout. + pub timeout_ms: u32, + /// Slot routing mode. + pub routing: RoutingMode, + /// Common notification policy. + pub notifications: NotificationPolicy, + /// Compiled protocol behavior. + pub protocol: ProtocolPlan, + /// Compiled typed profile behavior. + pub profile: CompiledOpenRtbProfile, +} + +/// Immutable target-independent auction plan. +#[derive(Debug, Clone)] +pub struct AuctionPlan { + enabled: bool, + providers: Vec, + bidder_routes: BTreeMap, + signing_enabled: bool, + mediator: Option, +} + +impl AuctionPlan { + /// Return whether auction execution is enabled. + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled + } + + pub(crate) fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Compile a deterministic plan without adapter-specific validation. + /// + /// # Errors + /// + /// Returns a configuration error for invalid identifiers, protocol/profile + /// declarations, endpoints, profile configuration, routes, notifications, + /// signing structure, or mediator selection. + pub fn compile(config: AuctionPlanConfig) -> Result> { + if config.timeout_ms == 0 { + return Err(configuration_error( + "auction timeout_ms must be greater than zero", + )); + } + validate_mediator(config.mediator.as_deref())?; + let signing_enabled = compile_signing_enabled(config.request_signing.as_ref())?; + let mut providers = Vec::with_capacity(config.providers.len()); + let mut provider_indices = BTreeMap::new(); + for (id, raw) in config.providers { + if raw.protocol != "openrtb-2.6" { + return Err(configuration_error(format!( + "provider `{id}` uses unsupported protocol `{}`", + raw.protocol + ))); + } + let registration = find_profile(&raw.profile).ok_or_else(|| { + configuration_error(format!( + "provider `{id}` uses unknown OpenRTB profile `{}`", + raw.profile + )) + })?; + if !raw.profile_config.is_object() { + return Err(configuration_error(format!( + "provider `{id}` profile_config must be an object" + ))); + } + let endpoint = canonicalize_endpoint(&id, registration.id, &raw.endpoint)?; + let timeout_ms = raw + .timeout_ms + .unwrap_or(match registration.default_timeout { + ProfileTimeoutDefault::Auction => config.timeout_ms, + ProfileTimeoutDefault::Fixed(value) => value, + }); + if timeout_ms == 0 { + return Err(configuration_error(format!( + "provider `{id}` timeout_ms must be greater than zero" + ))); + } + let notifications = compile_notifications(&id, raw.notifications)?; + let profile = registration.compile(&raw.profile_config)?; + let index = providers.len(); + provider_indices.insert(id.clone(), index); + providers.push(ProviderPlan { + id, + endpoint, + timeout_ms, + routing: raw.routing, + notifications, + protocol: ProtocolPlan::OpenRtb26, + profile, + }); + } + let mut bidder_routes = BTreeMap::new(); + for (bidder, route) in config.bidders { + if bidder.as_str() == RESERVED_BROWSER_ENVELOPE_BIDDER_ID { + return Err(configuration_error(format!( + "bidder ID `{RESERVED_BROWSER_ENVELOPE_BIDDER_ID}` is reserved for browser admission" + ))); + } + let provider_index = + provider_indices + .get(&route.provider) + .copied() + .ok_or_else(|| { + configuration_error(format!( + "bidder `{bidder}` references unknown provider `{}`", + route.provider + )) + })?; + bidder_routes.insert(bidder, provider_index); + } + Ok(Self { + enabled: true, + providers, + bidder_routes, + signing_enabled, + mediator: config.mediator, + }) + } +} + +impl ProviderPlan { + /// Build the canonical backend specification with the configured timeout. + #[must_use] + pub(crate) fn backend_spec(&self) -> PlatformBackendSpec { + self.backend_spec_with_transport_timeout(self.timeout_ms) + } + + /// Build the canonical backend specification with request-local transport timers. + #[must_use] + pub(crate) fn backend_spec_with_transport_timeout( + &self, + transport_timeout_ms: u32, + ) -> PlatformBackendSpec { + let endpoint = self.endpoint.url(); + let timeout = Duration::from_millis(u64::from(transport_timeout_ms)); + PlatformBackendSpec { + scheme: endpoint.scheme().to_owned(), + host: endpoint + .host_str() + .expect("should retain validated provider endpoint host") + .to_owned(), + port: endpoint.port(), + host_header_override: None, + certificate_check: true, + first_byte_timeout: timeout, + between_bytes_timeout: timeout, + discriminator: Some(self.id.as_str().to_owned()), + } + } +} + +impl AuctionPlan { + /// Validate adapter capabilities and backend-name correlation before I/O. + /// + /// Each provider is predicted from a canonical backend specification using + /// its exact configured provider timeout as both transport timers and its + /// provider ID as the stable discriminator. These transport timers do not + /// replace the auction-wide logical budget. + /// + /// # Errors + /// + /// Returns a configuration error when the target cannot fan out to every + /// configured provider, backend prediction fails, or two predicted names + /// collide. + pub fn validate_for_target( + &self, + target_id: AuctionTargetId, + ) -> Result<(), Report> { + if !self.enabled { + return Ok(()); + } + let target = target_id.descriptor(); + if self.providers.len() > 1 && !target.capabilities().supports_concurrent_provider_fanout() + { + return Err(configuration_error(format!( + "auction target `{}` does not support concurrent provider fanout; configured {} providers", + target_id.adapter_id(), + self.providers.len() + ))); + } + + let mut predicted_names = BTreeMap::::new(); + for provider in &self.providers { + let spec = provider.backend_spec(); + let prediction = target.naming_policy().predict(&spec).change_context( + TrustedServerError::Configuration { + message: format!( + "provider `{}` backend prediction failed for target `{}`", + provider.id, + target_id.adapter_id() + ), + }, + )?; + if let Some(existing) = predicted_names.insert(prediction.name.clone(), &provider.id) { + return Err(configuration_error(format!( + "providers `{existing}` and `{}` predict the same backend name `{}` for target `{}`", + provider.id, + prediction.name, + target_id.adapter_id() + ))); + } + } + Ok(()) + } + + /// Borrow compiled providers in deterministic provider-ID order. + #[must_use] + pub fn providers(&self) -> &[ProviderPlan] { + &self.providers + } + + /// Borrow a compiled provider by its validated identity. + #[must_use] + pub(crate) fn provider(&self, id: &ProviderId) -> Option<&ProviderPlan> { + self.providers.iter().find(|provider| provider.id == *id) + } + + /// Return whether any compiled provider uses the named profile. + /// + /// This narrow query allows capability activation to follow the validated + /// plan without exposing profile configuration. + #[must_use] + pub fn has_profile(&self, profile_id: &str) -> bool { + self.providers + .iter() + .any(|provider| provider.profile.id() == profile_id) + } + + /// Borrow validated client-visible bidder route codes in deterministic order. + /// + /// This intentionally exposes route keys rather than provider identities or + /// profile configuration for the browser Prebid injection boundary. + pub(crate) fn browser_bidder_codes(&self) -> impl Iterator { + self.bidder_routes.keys().map(BidderId::as_str) + } + + /// Resolve a bidder route to a compiled provider. + #[must_use] + pub fn provider_for_bidder(&self, bidder: &BidderId) -> Option<&ProviderPlan> { + self.bidder_routes + .get(bidder) + .and_then(|index| self.providers.get(*index)) + } + + /// Return whether auction-wide signing is enabled. + #[must_use] + pub fn signing_enabled(&self) -> bool { + self.signing_enabled + } + + /// Borrow the separately validated static mediator identifier. + #[must_use] + pub fn mediator(&self) -> Option<&str> { + self.mediator.as_deref() + } +} + +fn default_profile() -> String { + "standard".to_string() +} + +fn empty_object() -> Value { + Value::Object(serde_json::Map::new()) +} + +fn configuration_error(message: impl Into) -> Report { + Report::new(TrustedServerError::Configuration { + message: message.into(), + }) +} + +fn compile_signing_enabled( + request_signing: Option<&RequestSigning>, +) -> Result> { + let Some(request_signing) = request_signing else { + return Ok(false); + }; + if request_signing.enabled + && (request_signing.config_store_id.trim().is_empty() + || request_signing.secret_store_id.trim().is_empty()) + { + return Err(configuration_error( + "enabled request_signing requires nonblank config_store_id and secret_store_id", + )); + } + Ok(request_signing.enabled) +} + +fn validate_mediator(mediator: Option<&str>) -> Result<(), Report> { + if mediator.is_some_and(|value| value != MOCK_MEDIATOR_ID) { + return Err(configuration_error(format!( + "auction mediator must be `{MOCK_MEDIATOR_ID}` when configured" + ))); + } + Ok(()) +} + +fn canonicalize_endpoint( + provider_id: &ProviderId, + profile_id: &str, + value: &str, +) -> Result> { + let mut endpoint = Url::parse(value).map_err(|error| { + configuration_error(format!( + "provider `{provider_id}` endpoint must be an absolute HTTPS URL: {error}" + )) + })?; + if endpoint.scheme() != "https" + || endpoint.host_str().is_none() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.fragment().is_some() + { + return Err(configuration_error(format!( + "provider `{provider_id}` endpoint must be absolute HTTPS with a host and no credentials or fragment" + ))); + } + if profile_id == "aps" + && endpoint + .path() + .trim_end_matches('/') + .ends_with("/e/dtb/bid") + { + return Err(configuration_error(format!( + "provider `{provider_id}` uses unsupported legacy APS endpoint `/e/dtb/bid`" + ))); + } + endpoint.set_fragment(None); + Ok(CanonicalProviderEndpoint(endpoint)) +} + +fn compile_notifications( + provider_id: &ProviderId, + config: NotificationConfig, +) -> Result> { + if config.suppress_seats.len() > MAX_SUPPRESS_SEATS { + return Err(configuration_error(format!( + "provider `{provider_id}` notifications.suppress_seats exceeds {MAX_SUPPRESS_SEATS} entries" + ))); + } + let mut seats = BTreeSet::new(); + for seat in config.suppress_seats { + if seat.is_empty() + || seat.len() > MAX_SUPPRESS_SEAT_BYTES + || seat.chars().any(|character| character.is_ascii_control()) + { + return Err(configuration_error(format!( + "provider `{provider_id}` notification seat must be nonempty, at most {MAX_SUPPRESS_SEAT_BYTES} UTF-8 bytes, and contain no ASCII control characters" + ))); + } + if !seats.insert(seat.clone()) { + return Err(configuration_error(format!( + "provider `{provider_id}` notification seat `{seat}` is duplicated" + ))); + } + } + Ok(NotificationPolicy { + suppress_all: config.suppress_all, + suppress_seats: seats, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::profile::CompiledOpenRtbProfile; + + fn provider(profile: &str) -> ProviderConfig { + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: profile.to_string(), + endpoint: "https://bid.example/openrtb2/auction".to_string(), + timeout_ms: None, + routing: RoutingMode::Explicit, + notifications: NotificationConfig::default(), + profile_config: empty_object(), + } + } + + fn config(providers: BTreeMap) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 1500, + providers, + ..AuctionPlanConfig::default() + } + } + + fn id(value: &str) -> ProviderId { + ProviderId::from_str(value).expect("should parse provider ID") + } + + fn bidder(value: &str) -> BidderId { + BidderId::from_str(value).expect("should parse bidder ID") + } + + fn nested_object(levels: usize) -> Value { + let mut value = Value::String("leaf".to_string()); + for level in 0..levels { + value = Value::Object(serde_json::Map::from_iter([( + format!("level-{level}"), + value, + )])); + } + value + } + + fn nested_array(levels: usize) -> Value { + let mut value = Value::String("leaf".to_string()); + for _ in 0..levels { + value = Value::Array(vec![value]); + } + value + } + + #[test] + fn target_validation_accepts_fanout_and_rejects_unsupported_targets() { + let providers = BTreeMap::from([ + (id("provider-one"), provider("standard")), + (id("provider-two"), provider("standard")), + ]); + let plan = AuctionPlan::compile(config(providers)).expect("should compile plan"); + + assert!( + plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) + .is_ok(), + "Fastly should accept provider fanout" + ); + assert!( + plan.validate_for_target(crate::platform::AuctionTargetId::Axum) + .is_ok(), + "Axum should accept provider fanout" + ); + for target in [ + crate::platform::AuctionTargetId::Cloudflare, + crate::platform::AuctionTargetId::Spin, + ] { + let error = plan + .validate_for_target(target) + .expect_err("should reject unsupported provider fanout"); + assert!( + error.to_string().contains("fanout"), + "should explain fanout rejection: {error:?}" + ); + } + } + + #[test] + fn disabled_target_validation_skips_fanout_and_collision_checks() { + let providers = BTreeMap::from([ + (id("provider-one"), provider("standard")), + (id("provider-two"), provider("standard")), + ]); + let disabled = AuctionPlan::compile(config(providers)) + .expect("should compile plan") + .with_enabled(false); + + for target in [ + crate::platform::AuctionTargetId::Cloudflare, + crate::platform::AuctionTargetId::Spin, + ] { + disabled + .validate_for_target(target) + .expect("disabled dormant providers should skip target validation"); + } + + let provider = disabled.providers[0].clone(); + let disabled_collision = AuctionPlan { + enabled: false, + providers: vec![provider.clone(), provider], + bidder_routes: BTreeMap::new(), + signing_enabled: false, + mediator: None, + }; + disabled_collision + .validate_for_target(crate::platform::AuctionTargetId::Axum) + .expect("disabled dormant providers should skip collision validation"); + } + + #[test] + fn target_validation_keeps_same_origin_timeout_profile_instances_distinct() { + let shared = ProviderConfig { + timeout_ms: Some(777), + ..provider("standard") + }; + let plan = AuctionPlan::compile(config(BTreeMap::from([ + (id("provider-one"), shared.clone()), + (id("provider-two"), shared), + ]))) + .expect("should compile same-origin provider instances"); + + for target in [ + crate::platform::AuctionTargetId::Fastly, + crate::platform::AuctionTargetId::Axum, + ] { + plan.validate_for_target(target) + .expect("provider ID discriminators should prevent predicted collisions"); + } + } + + #[test] + fn target_validation_rejects_predicted_name_collisions() { + let compiled = AuctionPlan::compile(config(BTreeMap::from([( + id("provider-a"), + provider("standard"), + )]))) + .expect("should compile plan"); + let provider = compiled.providers[0].clone(); + // The compiler prevents duplicate provider IDs. Construct the otherwise + // impossible duplicate internally to pin validation's defense-in-depth + // collision rejection independently of compiler invariants. + let collision_plan = AuctionPlan { + enabled: true, + providers: vec![provider.clone(), provider], + bidder_routes: BTreeMap::new(), + signing_enabled: false, + mediator: None, + }; + + let error = collision_plan + .validate_for_target(crate::platform::AuctionTargetId::Axum) + .expect_err("should reject predicted backend collision"); + assert!(error.to_string().contains("same backend name")); + } + + #[test] + fn provider_id_enforces_exact_grammar_and_bounds() { + for valid in ["a", "pbs-primary", &format!("a{}", "0".repeat(62))] { + assert!(ProviderId::from_str(valid).is_ok(), "should accept {valid}"); + } + for invalid in [ + "", + "A", + "1provider", + "provider_name", + "provider.name", + "provider/one", + &format!("a{}", "0".repeat(63)), + ] { + assert!( + ProviderId::from_str(invalid).is_err(), + "should reject {invalid}" + ); + } + } + + #[test] + fn bidder_id_enforces_admission_bounds() { + assert!(BidderId::from_str("exampleBidder").is_ok()); + for invalid in ["", " bidder", "bidder\n", &"a".repeat(129)] { + assert!(BidderId::from_str(invalid).is_err()); + } + } + + #[test] + fn compiler_rejects_exact_reserved_browser_envelope_bidder_id() { + let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + raw.bidders.insert( + bidder("trustedServer"), + BidderRouteConfig { + provider: id("one"), + }, + ); + assert!( + AuctionPlan::compile(raw).is_err(), + "exact reserved bidder ID should be rejected" + ); + + let mut case_distinct = config(BTreeMap::from([(id("one"), provider("standard"))])); + case_distinct.bidders.insert( + bidder("TrustedServer"), + BidderRouteConfig { + provider: id("one"), + }, + ); + assert!( + AuctionPlan::compile(case_distinct).is_ok(), + "reserved bidder comparison should remain case-sensitive" + ); + } + + #[test] + fn compiler_orders_providers_and_routes_deterministically() { + let mut providers = BTreeMap::new(); + providers.insert(id("z-provider"), provider("standard")); + providers.insert(id("a-provider"), provider("standard")); + let mut raw = config(providers); + raw.bidders.insert( + bidder("z-bidder"), + BidderRouteConfig { + provider: id("z-provider"), + }, + ); + raw.bidders.insert( + bidder("a-bidder"), + BidderRouteConfig { + provider: id("a-provider"), + }, + ); + let plan = AuctionPlan::compile(raw).expect("should compile deterministic plan"); + assert_eq!(plan.providers()[0].id.as_str(), "a-provider"); + assert_eq!(plan.providers()[1].id.as_str(), "z-provider"); + assert_eq!( + plan.browser_bidder_codes().collect::>(), + vec!["a-bidder", "z-bidder"], + "browser query should return deduplicated route codes in deterministic order" + ); + assert_eq!( + plan.provider_for_bidder(&bidder("a-bidder")) + .map(|provider| provider.id.as_str()), + Some("a-provider") + ); + } + + #[test] + fn compiler_supports_two_instances_of_the_same_profile() { + let mut providers = BTreeMap::new(); + providers.insert(id("pbs-a"), provider("prebid-server")); + providers.insert(id("pbs-b"), provider("prebid-server")); + let plan = AuctionPlan::compile(config(providers)).expect("should compile two PBS plans"); + assert_eq!(plan.providers().len(), 2); + assert!( + plan.providers().iter().all(|provider| matches!( + provider.profile, + CompiledOpenRtbProfile::PrebidServer(_) + )) + ); + } + + #[test] + fn profile_defaults_and_explicit_timeout_override_are_resolved() { + let mut providers = BTreeMap::new(); + providers.insert(id("standard-one"), provider("standard")); + providers.insert(id("pbs-one"), provider("prebid-server")); + providers.insert( + id("aps-one"), + ProviderConfig { + endpoint: "https://aps.example/e/pb/bid".to_string(), + profile_config: serde_json::json!({"account_id": "example-account"}), + ..provider("aps") + }, + ); + providers.insert( + id("pbs-override"), + ProviderConfig { + timeout_ms: Some(321), + ..provider("prebid-server") + }, + ); + let plan = AuctionPlan::compile(config(providers)).expect("should resolve timeouts"); + let timeouts = plan + .providers() + .iter() + .map(|provider| (provider.id.as_str(), provider.timeout_ms)) + .collect::>(); + assert_eq!(timeouts["standard-one"], 1500); + assert_eq!(timeouts["pbs-one"], 1000); + assert_eq!(timeouts["aps-one"], 800); + assert_eq!(timeouts["pbs-override"], 321); + } + + #[test] + fn profile_registry_is_independent_of_browser_configuration() { + let ids = crate::auction::profile::profile_registrations() + .iter() + .map(|registration| registration.id) + .collect::>(); + assert_eq!(ids, vec!["standard", "prebid-server", "aps"]); + let mut providers = BTreeMap::new(); + providers.insert(id("pbs"), provider("prebid-server")); + let plan = AuctionPlan::compile(config(providers)) + .expect("should compile without Settings or browser integration state"); + assert!(!plan.has_profile("aps")); + + let mut providers = BTreeMap::new(); + providers.insert( + id("aps-instance"), + ProviderConfig { + endpoint: "https://aps.example/e/pb/bid".to_string(), + profile_config: serde_json::json!({"account_id": "example-account"}), + ..provider("aps") + }, + ); + let plan = AuctionPlan::compile(config(providers)).expect("should compile APS plan"); + assert!( + plan.has_profile("aps"), + "validated plan should expose APS renderer capability" + ); + assert!(!plan.has_profile("prebid-server")); + } + + #[test] + fn compiler_rejects_unknown_protocol_profile_and_route() { + let mut unknown_protocol = provider("standard"); + unknown_protocol.protocol = "openrtb-2.5".to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("one"), unknown_protocol)]))).is_err() + ); + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("one"), provider("unknown"))]))) + .is_err() + ); + let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + raw.bidders.insert( + bidder("example"), + BidderRouteConfig { + provider: id("missing"), + }, + ); + assert!(AuctionPlan::compile(raw).is_err()); + } + + #[test] + fn compiler_canonicalizes_https_endpoints_and_rejects_unsafe_forms() { + let mut canonical = provider("standard"); + canonical.endpoint = "https://BID.EXAMPLE:443/path".to_string(); + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), canonical)]))) + .expect("should canonicalize endpoint"); + assert_eq!( + plan.providers()[0].endpoint.as_str(), + "https://bid.example/path" + ); + for endpoint in [ + "http://bid.example/path", + "https://", + "https://user@bid.example/path", + "https://bid.example/path#fragment", + "/relative", + ] { + let mut raw_provider = provider("standard"); + raw_provider.endpoint = endpoint.to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("one"), raw_provider)]))).is_err(), + "should reject {endpoint}" + ); + } + let mut aps = provider("aps"); + aps.endpoint = "https://aps.example/e/dtb/bid".to_string(); + aps.profile_config = serde_json::json!({"account_id": "example-account"}); + assert!(AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))).is_err()); + } + + #[test] + fn standard_extensions_are_typed_bounded_and_cannot_claim_reserved_fields() { + let mut valid = provider("standard"); + valid.profile_config = serde_json::json!({ + "request_ext": {"fictional_account": "example"}, + "imp_ext": {"placement_group": "display"} + }); + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), valid)]))) + .expect("should compile static extensions"); + let CompiledOpenRtbProfile::Standard(standard) = &plan.providers()[0].profile else { + panic!("should compile standard profile") + }; + assert_eq!( + standard.request_ext.as_object()["fictional_account"], + "example" + ); + + let mut standard_owned_fields = provider("standard"); + standard_owned_fields.profile_config = serde_json::json!({ + "request_ext": { + "account": "example-account", + "sdk": {"source": "example"}, + "prebid": {"example": true} + }, + "imp_ext": {"prebid": {"example": true}} + }); + AuctionPlan::compile(config(BTreeMap::from([( + id("standard-owned-fields"), + standard_owned_fields, + )]))) + .expect("should allow standard static extensions outside common-owned fields"); + + for profile_config in [ + serde_json::json!({"request_ext": "bad"}), + serde_json::json!({"request_ext": {"trusted_server": {}}}), + ] { + let mut invalid = provider("standard"); + invalid.profile_config = profile_config; + assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); + } + let oversized = "x".repeat(16 * 1024); + let mut invalid = provider("standard"); + invalid.profile_config = serde_json::json!({"request_ext": {"value": oversized}}); + assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); + + let too_many_keys = (0..257) + .map(|index| (format!("key-{index}"), Value::Bool(true))) + .collect::>(); + let mut invalid = provider("standard"); + invalid.profile_config = serde_json::json!({"imp_ext": too_many_keys}); + assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); + } + + #[test] + fn standard_extension_depth_counts_container_levels() { + let mut object_valid = provider("standard"); + object_valid.profile_config = serde_json::json!({"request_ext": nested_object(8)}); + AuctionPlan::compile(config(BTreeMap::from([(id("object-valid"), object_valid)]))) + .expect("should accept eight nested object levels"); + + let mut object_invalid = provider("standard"); + object_invalid.profile_config = serde_json::json!({"request_ext": nested_object(9)}); + assert!( + AuctionPlan::compile(config(BTreeMap::from([( + id("object-invalid"), + object_invalid, + )]))) + .is_err(), + "should reject nine nested object levels" + ); + + let mut array_valid = provider("standard"); + array_valid.profile_config = serde_json::json!({"request_ext": {"value": nested_array(7)}}); + AuctionPlan::compile(config(BTreeMap::from([(id("array-valid"), array_valid)]))) + .expect("should accept one object plus seven nested array levels"); + + let mut array_invalid = provider("standard"); + array_invalid.profile_config = + serde_json::json!({"request_ext": {"value": nested_array(8)}}); + assert!( + AuctionPlan::compile(config(BTreeMap::from([( + id("array-invalid"), + array_invalid, + )]))) + .is_err(), + "should reject one object plus eight nested array levels" + ); + } + + #[test] + fn notification_policy_rejects_duplicates_and_bounds() { + let mut valid = provider("standard"); + valid.notifications = NotificationConfig { + suppress_all: true, + suppress_seats: vec!["seat-b".to_string(), "seat-a".to_string()], + }; + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), valid)]))) + .expect("should compile notifications"); + assert_eq!( + plan.providers()[0] + .notifications + .suppress_seats + .iter() + .map(String::as_str) + .collect::>(), + vec!["seat-a", "seat-b"] + ); + for seats in [ + vec!["same".to_string(), "same".to_string()], + vec![String::new()], + vec!["bad\nseat".to_string()], + vec!["x".repeat(129)], + (0..129).map(|index| format!("seat-{index}")).collect(), + ] { + let mut invalid = provider("standard"); + invalid.notifications.suppress_seats = seats; + assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); + } + } + + #[test] + fn typed_profile_config_rejects_unknown_fields_and_validates_aps_pairing() { + let mut pbs = provider("prebid-server"); + pbs.profile_config = serde_json::json!({"browser_only": true}); + assert!(AuctionPlan::compile(config(BTreeMap::from([(id("pbs"), pbs)]))).is_err()); + + let mut non_object = provider("standard"); + non_object.profile_config = Value::Null; + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("standard"), non_object,)]))).is_err() + ); + + let mut aps = provider("aps"); + aps.endpoint = "https://aps.example/e/pb/bid".to_string(); + aps.profile_config = serde_json::json!({ + "account_id": "example-account", + "inventory_domain": "publisher.example" + }); + assert!(AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))).is_err()); + } + + #[test] + fn enabled_signing_requires_nonblank_existing_global_store_ids() { + let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + raw.request_signing = Some(RequestSigning { + enabled: true, + config_store_id: " ".to_string(), + secret_store_id: "example-secret-store".to_string(), + }); + assert!( + AuctionPlan::compile(raw).is_err(), + "should reject enabled signing without a config store ID" + ); + + let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + raw.request_signing = Some(RequestSigning { + enabled: true, + config_store_id: "example-config-store".to_string(), + secret_store_id: "\t".to_string(), + }); + assert!( + AuctionPlan::compile(raw).is_err(), + "should reject enabled signing without a secret store ID" + ); + } + + #[test] + fn routing_signing_and_static_mediator_are_preserved_in_plan() { + let mut provider = provider("standard"); + provider.routing = RoutingMode::AllEligible; + let mut raw = config(BTreeMap::from([(id("one"), provider)])); + raw.request_signing = Some(RequestSigning { + enabled: true, + config_store_id: "example-config-store".to_string(), + secret_store_id: "example-secret-store".to_string(), + }); + raw.mediator = Some(MOCK_MEDIATOR_ID.to_string()); + let plan = AuctionPlan::compile(raw).expect("should compile common policies"); + assert_eq!(plan.providers()[0].routing, RoutingMode::AllEligible); + assert!(plan.signing_enabled()); + assert_eq!(plan.mediator(), Some(MOCK_MEDIATOR_ID)); + + let mut invalid = config(BTreeMap::new()); + invalid.mediator = Some("generic-mediator".to_string()); + assert!(AuctionPlan::compile(invalid).is_err()); + } +} diff --git a/crates/trusted-server-core/src/auction/profile.rs b/crates/trusted-server-core/src/auction/profile.rs new file mode 100644 index 000000000..8f5b25806 --- /dev/null +++ b/crates/trusted-server-core/src/auction/profile.rs @@ -0,0 +1,325 @@ +//! Compile-time `OpenRTB` profile registry and typed profile plans. + +use std::collections::BTreeMap; + +use error_stack::Report; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::consent_config::ConsentForwardingMode; +use crate::error::TrustedServerError; +use crate::integrations::aps::compile_profile_config as compile_aps_profile_config; +use crate::integrations::prebid::{ + BidParamOverrideEngine, BidParamOverrideRule, compile_profile_override_rules, +}; + +const STANDARD_PROFILE_ID: &str = "standard"; +const PREBID_PROFILE_ID: &str = "prebid-server"; +const APS_PROFILE_ID: &str = "aps"; +const STATIC_EXTENSION_MAX_BYTES: usize = 16 * 1024; +const STATIC_EXTENSION_MAX_DEPTH: usize = 8; +const STATIC_EXTENSION_MAX_KEYS: usize = 256; + +/// A registered profile's provider-timeout default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileTimeoutDefault { + /// Inherit the configured auction timeout. + Auction, + /// Use this fixed profile timeout. + Fixed(u32), +} + +/// Compile-time profile registration. +#[derive(Clone, Copy)] +pub struct OpenRtbProfileRegistration { + /// Stable profile identifier used by configuration. + pub id: &'static str, + /// Profile timeout used when a provider has no explicit override. + pub default_timeout: ProfileTimeoutDefault, + compile: fn(&Value) -> Result>, +} + +impl core::fmt::Debug for OpenRtbProfileRegistration { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("OpenRtbProfileRegistration") + .field("id", &self.id) + .field("default_timeout", &self.default_timeout) + .finish_non_exhaustive() + } +} + +impl OpenRtbProfileRegistration { + pub(crate) fn compile( + self, + config: &Value, + ) -> Result> { + (self.compile)(config) + } +} + +/// Immutable, typed profile behavior selected during plan compilation. +#[derive(Debug, Clone)] +pub enum CompiledOpenRtbProfile { + /// Generic `OpenRTB` 2.6 profile. + Standard(StandardProfilePlan), + /// Prebid Server compatibility profile. + PrebidServer(PrebidProfilePlan), + /// APS `OpenRTB` compatibility profile. + Aps(ApsProfilePlan), +} + +impl CompiledOpenRtbProfile { + /// Return the stable profile identifier. + #[must_use] + pub fn id(&self) -> &'static str { + match self { + Self::Standard(_) => STANDARD_PROFILE_ID, + Self::PrebidServer(_) => PREBID_PROFILE_ID, + Self::Aps(_) => APS_PROFILE_ID, + } + } + + /// Return whether this plan uses the Prebid Server profile. + #[must_use] + pub(crate) fn is_prebid_server(&self) -> bool { + matches!(self, Self::PrebidServer(_)) + } +} + +/// Validated static extension object. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct StaticExtension(Map); + +impl StaticExtension { + /// Borrow the validated extension object. + #[must_use] + pub fn as_object(&self) -> &Map { + &self.0 + } +} + +/// Compiled generic `OpenRTB` profile configuration. +#[derive(Debug, Clone, Default)] +pub struct StandardProfilePlan { + /// Static request-level extension fields. + pub request_ext: StaticExtension, + /// Static impression-level extension fields. + pub imp_ext: StaticExtension, +} + +/// Compiled Prebid profile configuration. +#[derive(Debug, Clone)] +pub struct PrebidProfilePlan { + /// Include Prebid HTTP exchange diagnostics. + pub debug: bool, + /// Set `OpenRTB` test mode. + pub test_mode: bool, + /// Optional query fragment appended to the page URL under legacy rules. + pub debug_query_params: Option, + /// Compiled override matching and merge index. + pub(crate) override_engine: BidParamOverrideEngine, + /// Consent transport policy. + pub consent_forwarding: ConsentForwardingMode, +} + +/// Compiled APS profile configuration. +#[derive(Debug, Clone)] +pub struct ApsProfilePlan { + /// APS account identifier. + pub account_id: String, + /// Include APS request/response diagnostics. + pub debug: bool, + /// Permit APS script creatives. + pub allow_script_creatives: bool, + /// Optional authorized inventory domain. + pub inventory_domain: Option, + /// Optional canonical inventory page origin. + pub inventory_page_origin: Option, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] +struct StandardProfileConfig { + #[serde(default)] + request_ext: Option, + #[serde(default)] + imp_ext: Option, +} + +/// Typed operator configuration compiled into a [`PrebidProfilePlan`]. +#[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub(crate) struct PrebidProfileConfig { + #[serde(default)] + debug: bool, + #[serde(default)] + test_mode: bool, + #[serde(default)] + debug_query_params: Option, + #[serde(default)] + bid_param_zone_overrides: BTreeMap>>, + #[serde(default)] + bid_param_overrides: BTreeMap>, + #[serde(default)] + bid_param_override_rules: Vec, + #[serde(default)] + consent_forwarding: ConsentForwardingMode, +} + +const PROFILE_REGISTRATIONS: [OpenRtbProfileRegistration; 3] = [ + OpenRtbProfileRegistration { + id: STANDARD_PROFILE_ID, + default_timeout: ProfileTimeoutDefault::Auction, + compile: compile_standard, + }, + OpenRtbProfileRegistration { + id: PREBID_PROFILE_ID, + default_timeout: ProfileTimeoutDefault::Fixed(1000), + compile: compile_prebid, + }, + OpenRtbProfileRegistration { + id: APS_PROFILE_ID, + default_timeout: ProfileTimeoutDefault::Fixed(800), + compile: compile_aps, + }, +]; + +/// Return the compile-time profile registry. +#[must_use] +pub fn profile_registrations() -> &'static [OpenRtbProfileRegistration] { + &PROFILE_REGISTRATIONS +} + +pub(crate) fn find_profile(id: &str) -> Option { + profile_registrations() + .iter() + .copied() + .find(|registration| registration.id == id) +} + +fn configuration_error(message: impl Into) -> Report { + Report::new(TrustedServerError::Configuration { + message: message.into(), + }) +} + +fn deserialize_profile(id: &str, value: &Value) -> Result> +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_value(value.clone()) + .map_err(|error| configuration_error(format!("invalid `{id}` profile_config: {error}"))) +} + +fn compile_standard(value: &Value) -> Result> { + let config: StandardProfileConfig = deserialize_profile(STANDARD_PROFILE_ID, value)?; + Ok(CompiledOpenRtbProfile::Standard(StandardProfilePlan { + request_ext: validate_static_extension("request_ext", config.request_ext)?, + imp_ext: validate_static_extension("imp_ext", config.imp_ext)?, + })) +} + +fn compile_prebid(value: &Value) -> Result> { + let config: PrebidProfileConfig = deserialize_profile(PREBID_PROFILE_ID, value)?; + let override_engine = compile_profile_override_rules( + &config.bid_param_zone_overrides, + &config.bid_param_overrides, + &config.bid_param_override_rules, + )?; + Ok(CompiledOpenRtbProfile::PrebidServer(PrebidProfilePlan { + debug: config.debug, + test_mode: config.test_mode, + debug_query_params: config.debug_query_params, + override_engine, + consent_forwarding: config.consent_forwarding, + })) +} + +fn compile_aps(value: &Value) -> Result> { + let config = compile_aps_profile_config(value.clone())?; + Ok(CompiledOpenRtbProfile::Aps(ApsProfilePlan { + account_id: config.account_id, + debug: config.debug, + allow_script_creatives: config.allow_script_creatives, + inventory_domain: config.inventory_domain, + inventory_page_origin: config.inventory_page_origin, + })) +} + +fn validate_static_extension( + field: &str, + value: Option, +) -> Result> { + let Some(value) = value else { + return Ok(StaticExtension::default()); + }; + let object = value.as_object().ok_or_else(|| { + configuration_error(format!("standard profile {field} must be an object")) + })?; + let size = serde_json::to_vec(&value) + .map_err(|error| configuration_error(format!("cannot serialize {field}: {error}")))? + .len(); + if size > STATIC_EXTENSION_MAX_BYTES { + return Err(configuration_error(format!( + "standard profile {field} exceeds {STATIC_EXTENSION_MAX_BYTES} bytes" + ))); + } + validate_extension_value(field, &value, 0)?; + reject_reserved_fields(field, object)?; + Ok(StaticExtension(object.clone())) +} + +fn validate_extension_value( + field: &str, + value: &Value, + container_depth: usize, +) -> Result<(), Report> { + match value { + Value::Object(object) => { + let container_depth = container_depth + 1; + if container_depth > STATIC_EXTENSION_MAX_DEPTH { + return Err(configuration_error(format!( + "standard profile {field} exceeds nesting depth {STATIC_EXTENSION_MAX_DEPTH}" + ))); + } + if object.len() > STATIC_EXTENSION_MAX_KEYS { + return Err(configuration_error(format!( + "standard profile {field} object exceeds {STATIC_EXTENSION_MAX_KEYS} keys" + ))); + } + for nested in object.values() { + validate_extension_value(field, nested, container_depth)?; + } + } + Value::Array(array) => { + let container_depth = container_depth + 1; + if container_depth > STATIC_EXTENSION_MAX_DEPTH { + return Err(configuration_error(format!( + "standard profile {field} exceeds nesting depth {STATIC_EXTENSION_MAX_DEPTH}" + ))); + } + for nested in array { + validate_extension_value(field, nested, container_depth)?; + } + } + _ => {} + } + Ok(()) +} + +fn reject_reserved_fields( + field: &str, + object: &Map, +) -> Result<(), Report> { + let reserved: &[&str] = match field { + "request_ext" => &["trusted_server"], + _ => &[], + }; + if let Some(key) = reserved.iter().find(|key| object.contains_key(**key)) { + return Err(configuration_error(format!( + "standard profile {field} cannot claim reserved field `{key}`" + ))); + } + Ok(()) +} diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 766bd7a08..0a4fca155 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -1,15 +1,45 @@ //! Trait definition for auction providers. use core::any::Any; +use std::collections::HashSet; use async_trait::async_trait; -use error_stack::Report; +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt as _}; +use http::{Method, Request, StatusCode, header}; +use serde_json::{Value, json}; + +use crate::integrations::aps::{ApsDebugRequest, parse_planned_aps_response}; +use crate::integrations::prebid::{apply_prebid_transport_headers, parse_planned_prebid_response}; use crate::error::TrustedServerError; -use crate::platform::{PlatformPendingRequest, PlatformResponse, RuntimeServices}; +use crate::platform::{ + PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, +}; +use crate::request_signing::{RequestSigner, SigningParams}; +use super::openrtb::{ + OpenRtbBuildOutcome, RequestFinalization, apply_notification_policy, build_request, + extract_standard_response, unused_bidder_params_count, +}; +use super::plan::ProviderPlan; +use super::profile::CompiledOpenRtbProfile; +use super::routing::{ProviderAuctionInput, RoutedAuction}; use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; +const MAX_PLANNED_RESPONSE_BYTES: usize = 1024 * 1024; + +fn attach_provider_routing_metadata( + response: &mut AuctionResponse, + profile: &CompiledOpenRtbProfile, + input: &ProviderAuctionInput, +) { + response.metadata.insert( + "routing".to_string(), + json!({"unused_bidder_params_count": unused_bidder_params_count(profile, input)}), + ); +} + /// Provider-local state carried from request dispatch to response parsing. pub type ProviderParseState = Box; @@ -52,8 +82,11 @@ impl ProviderRequestOutcome { /// Trait implemented by all auction providers (Prebid, APS, GAM, etc.). #[async_trait(?Send)] pub trait AuctionProvider: Send + Sync { - /// Unique identifier for this provider (e.g., "prebid", "aps", "gam"). - fn provider_name(&self) -> &'static str; + /// Borrow this provider instance's unique validated identifier. + /// + /// Legacy providers may return a string literal; config-first providers + /// return their owned operator-defined [`super::plan::ProviderId`]. + fn provider_name(&self) -> &str; /// Submit a bid request to this provider. /// @@ -155,3 +188,394 @@ pub trait AuctionProvider: Send + Sync { None } } + +/// One immutable config-first `OpenRTB` provider instance. +/// +/// Every instance owns its validated provider identity and carries only its own +/// typed response state across transport. +pub(crate) struct GenericOpenRtbProvider { + plan: ProviderPlan, +} + +/// Typed state created by and returned to one [`GenericOpenRtbProvider`]. +#[allow( + dead_code, + clippy::large_enum_variant, + reason = "typed Stage 6 state avoids provider-state confusion; Stage 7/8 replace profile variants" +)] +pub(crate) enum GenericOpenRtbParseState { + Standard { + provider_id: String, + input: ProviderAuctionInput, + }, + Prebid { + provider_id: String, + auction_id: String, + input: ProviderAuctionInput, + }, + Aps { + provider_id: String, + input: ProviderAuctionInput, + debug_request: Option, + }, +} + +impl GenericOpenRtbProvider { + pub(crate) fn new(plan: ProviderPlan) -> Self { + Self { plan } + } + + pub(crate) fn provider_name(&self) -> &str { + self.plan.id.as_str() + } + + pub(crate) fn timeout_ms(&self) -> u32 { + self.plan.timeout_ms + } + + #[cfg(test)] + pub(crate) fn parse_state_for_test(&self, input: ProviderAuctionInput) -> ProviderParseState { + let state = match &self.plan.profile { + CompiledOpenRtbProfile::Standard(_) => GenericOpenRtbParseState::Standard { + provider_id: self.provider_name().to_string(), + input, + }, + CompiledOpenRtbProfile::PrebidServer(_) => GenericOpenRtbParseState::Prebid { + provider_id: self.provider_name().to_string(), + auction_id: input.common_request().id.clone(), + input, + }, + CompiledOpenRtbProfile::Aps(_) => GenericOpenRtbParseState::Aps { + provider_id: self.provider_name().to_string(), + input, + debug_request: None, + }, + }; + Box::new(state) + } + + /// Build, register, and start exactly one routed provider request. + /// + /// The public [`AuctionProvider::request_bids`] seam cannot carry a + /// [`ProviderAuctionInput`], the complete [`RoutedAuction`], or an + /// auction-local [`RequestSigner`] without shared mutable provider state. + /// The plan-backed split dispatcher therefore supplies that explicit + /// execution context here, while this method reuses + /// [`ProviderRequestOutcome`] and [`ProviderParseState`] for the existing + /// request/parse token boundary. + #[allow( + clippy::too_many_arguments, + reason = "the internal driver keeps routed inputs, both budgets, signer, services, and collision state explicit" + )] + pub(crate) async fn request_bids_routed( + &self, + input: &ProviderAuctionInput, + routed: &RoutedAuction, + logical_budget_ms: u32, + transport_timeout_ms: u32, + signer: Option<&RequestSigner>, + services: &RuntimeServices, + reserved_backend_names: &mut HashSet, + ) -> Result> { + let signing_params = SigningParams::new( + input.common_request().id.clone(), + input.common_request().publisher.domain.clone(), + "https".to_string(), + ); + let request = match build_request( + input, + routed, + &self.plan, + logical_budget_ms, + &RequestFinalization { + signer, + signing_params, + }, + )? { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => { + return Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( + self.provider_name(), + 0, + ))); + } + }; + + let spec = self + .plan + .backend_spec_with_transport_timeout(transport_timeout_ms); + let predicted_name = + services + .backend() + .predict_name(&spec) + .change_context(TrustedServerError::Auction { + message: format!( + "Provider {} backend prediction failed", + self.provider_name() + ), + })?; + let backend_name = + services + .backend() + .ensure(&spec) + .change_context(TrustedServerError::Auction { + message: format!( + "Provider {} backend registration failed", + self.provider_name() + ), + })?; + if backend_name != predicted_name { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} backend registration did not match prediction", + self.provider_name() + ), + })); + } + if !reserved_backend_names.insert(backend_name.clone()) { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} resolved an actual backend name already owned by another provider", + self.provider_name() + ), + })); + } + + let body = serde_json::to_vec(&request).change_context(TrustedServerError::Auction { + message: format!( + "Provider {} request serialization failed", + self.provider_name() + ), + })?; + let mut outbound = Request::builder() + .method(Method::POST) + .uri(self.plan.endpoint.as_str()) + .header(header::CONTENT_TYPE, "application/json"); + if matches!(&self.plan.profile, CompiledOpenRtbProfile::Standard(_)) { + outbound = outbound.header(header::ACCEPT, "application/json"); + } + let aps_debug_body = matches!( + &self.plan.profile, + CompiledOpenRtbProfile::Aps(profile) if profile.debug + ) + .then(|| body.clone()); + let mut outbound = + outbound + .body(EdgeBody::from(body)) + .change_context(TrustedServerError::Auction { + message: format!( + "Provider {} request construction failed", + self.provider_name() + ), + })?; + let aps_debug_request = aps_debug_body + .as_deref() + .map(|body| ApsDebugRequest::capture(body, outbound.headers())); + if let CompiledOpenRtbProfile::PrebidServer(profile) = &self.plan.profile { + apply_prebid_transport_headers( + routed.prebid_transport_headers(), + &mut outbound, + profile.consent_forwarding, + routed.attested_client_ip(), + ); + } + let pending = services + .http_client() + .send_async(PlatformHttpRequest::new(outbound, backend_name.clone())) + .await + .change_context(TrustedServerError::Auction { + message: format!("Provider {} request launch failed", self.provider_name()), + })?; + if pending.backend_name() != Some(backend_name.as_str()) { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} pending request backend did not match registered backend", + self.provider_name() + ), + })); + } + let parse_state = match &self.plan.profile { + CompiledOpenRtbProfile::Standard(_) => GenericOpenRtbParseState::Standard { + provider_id: self.provider_name().to_string(), + input: input.clone(), + }, + CompiledOpenRtbProfile::PrebidServer(_) => GenericOpenRtbParseState::Prebid { + provider_id: self.provider_name().to_string(), + auction_id: input.common_request().id.clone(), + input: input.clone(), + }, + CompiledOpenRtbProfile::Aps(_) => GenericOpenRtbParseState::Aps { + provider_id: self.provider_name().to_string(), + input: input.clone(), + debug_request: aps_debug_request, + }, + }; + Ok(ProviderRequestOutcome::pending_with_state( + pending, + Box::new(parse_state), + )) + } + + /// Parse a response using state created by this exact provider instance. + pub(crate) async fn parse_response_with_state( + &self, + response: PlatformResponse, + response_time_ms: u64, + parse_state: Option<&(dyn Any + Send + Sync)>, + ) -> Result> { + let parse_state = parse_state + .and_then(|state| state.downcast_ref::()) + .ok_or_else(|| { + Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} received missing or invalid response state", + self.provider_name() + ), + }) + })?; + let state_provider_id = match parse_state { + GenericOpenRtbParseState::Standard { provider_id, .. } + | GenericOpenRtbParseState::Prebid { provider_id, .. } + | GenericOpenRtbParseState::Aps { provider_id, .. } => provider_id, + }; + if state_provider_id != self.provider_name() { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} received response state owned by provider {}", + self.provider_name(), + state_provider_id + ), + })); + } + + if let GenericOpenRtbParseState::Prebid { + auction_id, input, .. + } = parse_state + { + let CompiledOpenRtbProfile::PrebidServer(profile) = &self.plan.profile else { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} received PBS response state for profile {}", + self.provider_name(), + self.plan.profile.id() + ), + })); + }; + let mut parsed = match parse_planned_prebid_response( + self.provider_name(), + profile, + input, + response, + response_time_ms, + auction_id, + ) + .await + { + Ok(parsed) => parsed, + Err(error) => { + log::warn!( + "Provider '{}' PBS response parse failed: {:?}", + self.provider_name(), + error + ); + AuctionResponse::error(self.provider_name(), response_time_ms) + } + }; + apply_notification_policy(&mut parsed.bids, &self.plan.notifications); + attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); + return Ok(parsed); + } + + if let GenericOpenRtbParseState::Aps { + input, + debug_request, + .. + } = parse_state + { + let CompiledOpenRtbProfile::Aps(profile) = &self.plan.profile else { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} received APS response state for profile {}", + self.provider_name(), + self.plan.profile.id() + ), + })); + }; + let mut parsed = match parse_planned_aps_response( + self.provider_name(), + profile, + self.plan.endpoint.as_str(), + input, + response, + response_time_ms, + debug_request.clone(), + ) + .await + { + Ok(parsed) => parsed, + Err(error) => { + log::warn!( + "Provider '{}' APS response parse failed: {:?}", + self.provider_name(), + error + ); + let mut parsed = AuctionResponse::error(self.provider_name(), response_time_ms); + attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); + parsed + } + }; + apply_notification_policy(&mut parsed.bids, &self.plan.notifications); + return Ok(parsed); + } + + let response = response.response; + let status = response.status(); + let GenericOpenRtbParseState::Standard { input, .. } = parse_state else { + unreachable!("profile-specific states are handled before standard parsing"); + }; + if status == StatusCode::NO_CONTENT { + let mut parsed = AuctionResponse::no_bid(self.provider_name(), response_time_ms); + attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); + return Ok(parsed); + } + if !status.is_success() { + if status.is_redirection() { + log::warn!( + "Provider '{}' returned a redirect; generic OpenRTB redirects are refused", + self.provider_name() + ); + } + let mut parsed = AuctionResponse::error(self.provider_name(), response_time_ms) + .with_metadata("http_status", json!(status.as_u16())); + attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); + return Ok(parsed); + } + + let body = response + .into_body() + .into_bytes_bounded(MAX_PLANNED_RESPONSE_BYTES) + .await + .change_context(TrustedServerError::Auction { + message: format!("Provider {} response body failed", self.provider_name()), + })?; + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(error) => { + log::warn!( + "Provider '{}' response JSON was invalid: {}", + self.provider_name(), + error + ); + let mut parsed = AuctionResponse::error(self.provider_name(), response_time_ms); + attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); + return Ok(parsed); + } + }; + + let mut parsed = + extract_standard_response(self.provider_name(), input, &value, response_time_ms); + apply_notification_policy(&mut parsed.bids, &self.plan.notifications); + attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); + Ok(parsed) + } +} diff --git a/crates/trusted-server-core/src/auction/routing.rs b/crates/trusted-server-core/src/auction/routing.rs new file mode 100644 index 000000000..46ea98285 --- /dev/null +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -0,0 +1,1177 @@ +//! Internal admission normalization and config-first provider routing. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::net::IpAddr; + +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderValue, Request, header}; +use serde_json::Value; + +use super::plan::{AuctionPlan, BidderId, ProviderId, RoutingMode}; +use super::types::{AdSlot, AuctionRequest, MediaType}; + +const TRUSTED_SERVER_ENVELOPE: &str = "trustedServer"; +const BIDDER_PARAMS_FIELD: &str = "bidderParams"; +const ZONE_FIELD: &str = "zone"; + +/// Maximum bidder entries admitted from one browser `bidderParams` envelope. +pub(crate) const MAX_BIDDER_ENTRIES: usize = 128; +/// Maximum UTF-8 byte length of the optional Prebid zone fact. +pub(crate) const MAX_PREBID_ZONE_BYTES: usize = 256; + +/// Immutable provider-local routing output in deterministic provider-ID order. +#[derive(Debug, Clone)] +pub(crate) struct RoutedAuction { + inputs: Vec, + skipped_no_eligible_provider_ids: Vec, + diagnostics: RoutingDiagnostics, + transport_headers: PrebidTransportHeaders, + attested_client_ip: Option, + dnt: Option, +} + +impl RoutedAuction { + pub(crate) fn inputs(&self) -> &[ProviderAuctionInput] { + &self.inputs + } + + /// Providers skipped because they had no eligible banner slots. + /// + /// IDs retain the compiled plan's deterministic provider-ID order. + pub(crate) fn skipped_no_eligible_provider_ids(&self) -> &[ProviderId] { + &self.skipped_no_eligible_provider_ids + } + + pub(crate) fn diagnostics(&self) -> RoutingDiagnostics { + self.diagnostics + } + + /// Request headers approved for later Prebid transport forwarding. + pub(crate) fn prebid_transport_headers(&self) -> &PrebidTransportHeaders { + &self.transport_headers + } + + /// Platform-attested client IP for transport forwarding. + pub(crate) fn attested_client_ip(&self) -> Option { + self.attested_client_ip + } + + /// Normalized Do Not Track fact; raw request headers are not exposed to profiles. + pub(crate) fn dnt(&self) -> Option { + self.dnt + } +} + +/// Saturating, count-only routing diagnostics. +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] +pub(crate) struct RoutingDiagnostics { + unroutable_bidder_count: u32, + malformed_envelope_count: u32, + malformed_direct_demand_count: u32, + unroutable_trusted_provider_count: u32, +} + +impl RoutingDiagnostics { + pub(crate) fn unroutable_bidder_count(self) -> u32 { + self.unroutable_bidder_count + } + + #[cfg(test)] + pub(crate) fn malformed_envelope_count(self) -> u32 { + self.malformed_envelope_count + } + + #[cfg(test)] + pub(crate) fn malformed_direct_demand_count(self) -> u32 { + self.malformed_direct_demand_count + } + + #[cfg(test)] + pub(crate) fn unroutable_trusted_provider_count(self) -> u32 { + self.unroutable_trusted_provider_count + } + + fn record_unroutable_bidder(&mut self) { + self.unroutable_bidder_count = self.unroutable_bidder_count.saturating_add(1); + } + + fn record_malformed_envelope(&mut self) { + self.malformed_envelope_count = self.malformed_envelope_count.saturating_add(1); + } + + fn record_malformed_direct_demand(&mut self) { + self.malformed_direct_demand_count = self.malformed_direct_demand_count.saturating_add(1); + } + + fn record_unroutable_trusted_provider(&mut self) { + self.unroutable_trusted_provider_count = + self.unroutable_trusted_provider_count.saturating_add(1); + } + + #[cfg(test)] + pub(crate) fn saturated_for_test() -> Self { + let mut diagnostics = Self { + unroutable_bidder_count: u32::MAX, + ..Self::default() + }; + diagnostics.record_unroutable_bidder(); + diagnostics + } +} + +/// Provider-local immutable auction input. +#[derive(Debug, Clone)] +pub(crate) struct ProviderAuctionInput { + provider_id: ProviderId, + #[cfg_attr( + not(test), + allow( + dead_code, + reason = "retained in routed input to pin the provider budget invariant" + ) + )] + timeout_ms: u32, + common_request: AuctionRequest, + slots: Vec, +} + +impl ProviderAuctionInput { + pub(crate) fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + #[cfg(test)] + pub(crate) fn timeout_ms(&self) -> u32 { + self.timeout_ms + } + + /// Common privacy-approved request data. Its slot list is always empty. + pub(crate) fn common_request(&self) -> &AuctionRequest { + &self.common_request + } + + pub(crate) fn slots(&self) -> &[ProviderSlotInput] { + &self.slots + } +} + +/// One eligible slot with only the demand assigned to this provider. +#[derive(Debug, Clone)] +pub(crate) struct ProviderSlotInput { + slot: AdSlot, + bidder_params: BTreeMap, + prebid_zone: Option, + trusted_stored_request: bool, +} + +impl ProviderSlotInput { + /// Common slot facts. The legacy `bidders` map is always empty. + pub(crate) fn slot(&self) -> &AdSlot { + &self.slot + } + + pub(crate) fn bidder_params(&self) -> &BTreeMap { + &self.bidder_params + } + + pub(crate) fn prebid_zone(&self) -> Option<&str> { + self.prebid_zone.as_deref() + } + + pub(crate) fn has_trusted_stored_request(&self) -> bool { + self.trusted_stored_request + } +} + +/// Request headers approved for later Prebid transport forwarding. +/// +/// Values remain as raw [`HeaderValue`] instances so non-ASCII bytes retain +/// the same legacy handling. Client-supplied `X-Forwarded-For` is never read. +#[derive(Debug, Clone, Default)] +pub(crate) struct PrebidTransportHeaders { + cookie: Option, + user_agent: Option, + referer: Option, + accept_language: Option, +} + +impl PrebidTransportHeaders { + pub(crate) fn cookie(&self) -> Option<&HeaderValue> { + self.cookie.as_ref() + } + + pub(crate) fn user_agent(&self) -> Option<&HeaderValue> { + self.user_agent.as_ref() + } + + pub(crate) fn referer(&self) -> Option<&HeaderValue> { + self.referer.as_ref() + } + + pub(crate) fn accept_language(&self) -> Option<&HeaderValue> { + self.accept_language.as_ref() + } + + fn snapshot(request: &Request) -> Self { + Self { + cookie: request.headers().get(header::COOKIE).cloned(), + user_agent: request.headers().get(header::USER_AGENT).cloned(), + referer: request.headers().get(header::REFERER).cloned(), + accept_language: request.headers().get(header::ACCEPT_LANGUAGE).cloned(), + } + } +} + +/// Server-owned explicit provider routes aligned with canonical auction slots. +/// +/// This internal-only type has no deserializer, so browser input cannot select +/// a provider ID. The caller supplies one route list for each request slot. +#[derive(Debug, Default)] +pub(crate) struct TrustedProviderRoutes { + routes_by_slot: Vec>, +} + +impl TrustedProviderRoutes { + #[cfg(test)] + pub(crate) fn new(routes_by_slot: Vec>) -> Self { + Self { routes_by_slot } + } + + fn for_slot(&self, slot_index: usize) -> &[ProviderId] { + self.routes_by_slot + .get(slot_index) + .map_or(&[], Vec::as_slice) + } +} + +#[derive(Debug, Default)] +struct NormalizedSlotDemand { + bidder_params: BTreeMap, + stored_request: bool, + prebid_zone: Option, +} + +#[derive(Debug)] +struct ProviderInputBuilder { + provider_id: ProviderId, + timeout_ms: u32, + is_prebid: bool, + routing: RoutingMode, + slots: Vec, +} + +/// Normalize admitted demand and build deterministic provider-local inputs. +/// +/// This helper consumes the canonical request so the common request retained by +/// each provider can be scrubbed of slots. It performs no I/O. +pub(crate) fn route_auction( + request: AuctionRequest, + inbound_request: &Request, + plan: &AuctionPlan, + attested_client_ip: Option, +) -> RoutedAuction { + route_auction_with_trusted_routes( + request, + inbound_request, + plan, + attested_client_ip, + &TrustedProviderRoutes::default(), + ) +} + +/// Route an auction with server-owned explicit provider routes. +/// +/// Only server-generated entry points may construct [`TrustedProviderRoutes`]. +/// Browser/default admission must use [`route_auction`]. +pub(crate) fn route_auction_with_trusted_routes( + mut request: AuctionRequest, + inbound_request: &Request, + plan: &AuctionPlan, + attested_client_ip: Option, + trusted_routes: &TrustedProviderRoutes, +) -> RoutedAuction { + let slots = std::mem::take(&mut request.slots); + let common_request = request; + let transport_headers = PrebidTransportHeaders::snapshot(inbound_request); + let dnt = inbound_request + .headers() + .get("dnt") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.trim() == "1") + .then_some(true); + let mut diagnostics = RoutingDiagnostics::default(); + let mut builders = plan + .providers() + .iter() + .map(|provider| ProviderInputBuilder { + provider_id: provider.id.clone(), + timeout_ms: provider.timeout_ms, + is_prebid: provider.profile.is_prebid_server(), + routing: provider.routing, + slots: Vec::new(), + }) + .collect::>(); + let provider_indices = builders + .iter() + .enumerate() + .map(|(index, provider)| (provider.provider_id.clone(), index)) + .collect::>(); + + for (slot_index, slot) in slots.into_iter().enumerate() { + let Some(common_slot) = eligible_banner_slot(&slot) else { + continue; + }; + let demand = normalize_slot_demand(&slot.bidders, &mut diagnostics); + let mut routed_params = vec![BTreeMap::new(); builders.len()]; + for (bidder, params) in demand.bidder_params { + let Some(provider) = plan.provider_for_bidder(&bidder) else { + diagnostics.record_unroutable_bidder(); + continue; + }; + let provider_index = *provider_indices + .get(&provider.id) + .expect("should resolve compiled provider index"); + routed_params[provider_index].insert(bidder, params); + } + let mut trusted_provider_indices = BTreeSet::new(); + for provider_id in trusted_routes.for_slot(slot_index) { + let Some(provider_index) = provider_indices.get(provider_id).copied() else { + diagnostics.record_unroutable_trusted_provider(); + continue; + }; + trusted_provider_indices.insert(provider_index); + } + + for (provider_index, builder) in builders.iter_mut().enumerate() { + let bidder_params = std::mem::take(&mut routed_params[provider_index]); + let trusted_route = trusted_provider_indices.contains(&provider_index); + let trusted_stored_request = + builder.is_prebid && demand.stored_request && bidder_params.is_empty(); + let include = builder.routing == RoutingMode::AllEligible + || !bidder_params.is_empty() + || trusted_stored_request + || trusted_route; + if !include { + continue; + } + builder.slots.push(ProviderSlotInput { + slot: common_slot.clone(), + bidder_params, + prebid_zone: builder + .is_prebid + .then(|| demand.prebid_zone.clone()) + .flatten(), + trusted_stored_request, + }); + } + } + + let mut skipped_no_eligible_provider_ids = Vec::new(); + let inputs = builders + .into_iter() + .filter_map(|builder| { + if builder.slots.is_empty() { + skipped_no_eligible_provider_ids.push(builder.provider_id); + return None; + } + Some(ProviderAuctionInput { + provider_id: builder.provider_id, + timeout_ms: builder.timeout_ms, + common_request: common_request.clone(), + slots: builder.slots, + }) + }) + .collect(); + + RoutedAuction { + inputs, + skipped_no_eligible_provider_ids, + diagnostics, + transport_headers, + attested_client_ip, + dnt, + } +} + +fn eligible_banner_slot(slot: &AdSlot) -> Option { + let formats = slot + .formats + .iter() + .filter(|format| { + format.media_type == MediaType::Banner + && i32::try_from(format.width).is_ok_and(|width| width > 0) + && i32::try_from(format.height).is_ok_and(|height| height > 0) + }) + .cloned() + .collect::>(); + if formats.is_empty() { + return None; + } + Some(AdSlot { + id: slot.id.clone(), + formats, + floor_price: slot.floor_price, + targeting: slot.targeting.clone(), + bidders: HashMap::new(), + }) +} + +fn normalize_slot_demand( + bidders: &HashMap, + diagnostics: &mut RoutingDiagnostics, +) -> NormalizedSlotDemand { + if bidders.is_empty() { + return NormalizedSlotDemand { + stored_request: true, + ..Default::default() + }; + } + + let mut demand = NormalizedSlotDemand::default(); + if let Some(envelope) = bidders.get(TRUSTED_SERVER_ENVELOPE) { + match normalize_envelope(envelope) { + Some(normalized) => demand = normalized, + None => diagnostics.record_malformed_envelope(), + } + } + + let mut direct = bidders + .iter() + .filter(|(key, _)| key.as_str() != TRUSTED_SERVER_ENVELOPE) + .collect::>(); + direct.sort_by_key(|(left, _)| *left); + for (raw_bidder, params) in direct { + let Ok(bidder) = raw_bidder.parse::() else { + diagnostics.record_malformed_direct_demand(); + continue; + }; + if bidder.as_str() == TRUSTED_SERVER_ENVELOPE || !is_usable_params(params) { + diagnostics.record_malformed_direct_demand(); + continue; + } + demand.bidder_params.insert(bidder, params.clone()); + } + demand +} + +fn normalize_envelope(envelope: &Value) -> Option { + let object = envelope.as_object()?; + if object + .keys() + .any(|key| !matches!(key.as_str(), BIDDER_PARAMS_FIELD | ZONE_FIELD)) + { + return None; + } + let prebid_zone = match object.get(ZONE_FIELD) { + None => None, + Some(Value::String(zone)) if zone.len() <= MAX_PREBID_ZONE_BYTES => Some(zone.clone()), + Some(_) => return None, + }; + let Some(raw_params) = object.get(BIDDER_PARAMS_FIELD) else { + return Some(NormalizedSlotDemand { + stored_request: true, + prebid_zone, + ..Default::default() + }); + }; + if raw_params.is_null() { + return Some(NormalizedSlotDemand { + stored_request: true, + prebid_zone, + ..Default::default() + }); + } + let params = raw_params.as_object()?; + if params.is_empty() { + return Some(NormalizedSlotDemand { + stored_request: true, + prebid_zone, + ..Default::default() + }); + } + if params.len() > MAX_BIDDER_ENTRIES { + return None; + } + + let mut bidder_params = BTreeMap::new(); + for (raw_bidder, value) in params { + let bidder = raw_bidder.parse::().ok()?; + if bidder.as_str() == TRUSTED_SERVER_ENVELOPE || !is_usable_params(value) { + return None; + } + bidder_params.insert(bidder, value.clone()); + } + Some(NormalizedSlotDemand { + bidder_params, + stored_request: false, + prebid_zone, + }) +} + +fn is_usable_params(value: &Value) -> bool { + value.as_object().is_some_and(|object| !object.is_empty()) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use super::*; + use crate::auction::plan::{ + AuctionPlanConfig, BidderRouteConfig, NotificationConfig, ProviderConfig, + }; + use crate::auction::types::{AdFormat, DeviceInfo, PublisherInfo, SiteInfo, UserInfo}; + use http::HeaderName; + use serde_json::{Map, json}; + + fn provider(profile: &str, routing: RoutingMode) -> ProviderConfig { + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: profile.to_string(), + endpoint: format!("https://{profile}.example.test/openrtb"), + timeout_ms: None, + routing, + notifications: NotificationConfig::default(), + profile_config: if profile == "aps" { + json!({"account_id": "example-account"}) + } else { + json!({}) + }, + } + } + + fn plan() -> AuctionPlan { + AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 900, + providers: BTreeMap::from([ + ( + ProviderId::from_str("aps-primary").expect("should parse provider"), + provider("aps", RoutingMode::AllEligible), + ), + ( + ProviderId::from_str("pbs-a").expect("should parse provider"), + provider("prebid-server", RoutingMode::Explicit), + ), + ( + ProviderId::from_str("pbs-b").expect("should parse provider"), + provider("prebid-server", RoutingMode::Explicit), + ), + ( + ProviderId::from_str("standard-direct").expect("should parse provider"), + provider("standard", RoutingMode::Explicit), + ), + ]), + bidders: BTreeMap::from([ + ( + BidderId::from_str("alpha").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("pbs-a").expect("should parse provider"), + }, + ), + ( + BidderId::from_str("beta").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("standard-direct") + .expect("should parse provider"), + }, + ), + ]), + mediator: None, + request_signing: None, + }) + .expect("should compile plan") + } + + fn explicit_plan() -> AuctionPlan { + AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 900, + providers: BTreeMap::from([ + ( + ProviderId::from_str("aps-primary").expect("should parse provider"), + provider("aps", RoutingMode::Explicit), + ), + ( + ProviderId::from_str("pbs-a").expect("should parse provider"), + provider("prebid-server", RoutingMode::Explicit), + ), + ( + ProviderId::from_str("pbs-b").expect("should parse provider"), + provider("prebid-server", RoutingMode::Explicit), + ), + ( + ProviderId::from_str("standard-direct").expect("should parse provider"), + provider("standard", RoutingMode::Explicit), + ), + ]), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, + }) + .expect("should compile explicit plan") + } + + fn slot(bidders: HashMap) -> AdSlot { + AdSlot { + id: "slot-1".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: Some(0.5), + targeting: HashMap::new(), + bidders, + } + } + + fn request(slots: Vec) -> AuctionRequest { + AuctionRequest { + id: "auction-1".to_string(), + slots, + publisher: PublisherInfo { + domain: "publisher.example.test".to_string(), + page_url: Some("https://publisher.example.test/article".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: Some(DeviceInfo { + user_agent: None, + ip: None, + geo: None, + }), + site: Some(SiteInfo { + domain: "publisher.example.test".to_string(), + page: "https://publisher.example.test/article".to_string(), + }), + context: HashMap::new(), + } + } + + fn inbound() -> Request { + Request::builder() + .uri("https://publisher.example.test/auction") + .body(EdgeBody::empty()) + .expect("should build request") + } + + fn envelope(bidder_params: Option) -> Value { + let mut value = Map::new(); + if let Some(params) = bidder_params { + value.insert(BIDDER_PARAMS_FIELD.to_string(), params); + } + Value::Object(value) + } + + fn input<'a>(routed: &'a RoutedAuction, provider_id: &str) -> &'a ProviderAuctionInput { + routed + .inputs() + .iter() + .find(|input| input.provider_id().as_str() == provider_id) + .expect("should find provider input") + } + + #[test] + fn missing_null_and_empty_envelope_params_fan_out_stored_routes() { + let cases = [ + ("missing", envelope(None)), + ("null", envelope(Some(Value::Null))), + ("empty", envelope(Some(json!({})))), + ]; + for (name, trusted_server) in cases { + let routed = route_auction( + request(vec![slot(HashMap::from([( + TRUSTED_SERVER_ENVELOPE.to_string(), + trusted_server, + )]))]), + &inbound(), + &plan(), + None, + ); + let ids = routed + .inputs() + .iter() + .map(|input| input.provider_id().as_str()) + .collect::>(); + assert_eq!( + ids, + vec!["aps-primary", "pbs-a", "pbs-b"], + "{name} should fan out to both PBS providers while APS remains all-eligible" + ); + assert!( + input(&routed, "pbs-a").slots()[0].has_trusted_stored_request(), + "{name} should create stored intent" + ); + assert!( + input(&routed, "pbs-b").slots()[0].has_trusted_stored_request(), + "{name} should create stored intent for every PBS provider" + ); + } + } + + #[test] + fn entirely_empty_bidder_map_is_trusted_stored_intent() { + let routed = route_auction( + request(vec![slot(HashMap::new())]), + &inbound(), + &plan(), + None, + ); + assert!( + input(&routed, "pbs-a").slots()[0].has_trusted_stored_request(), + "empty canonical demand should preserve stored-request behavior" + ); + assert!( + input(&routed, "pbs-b").slots()[0].has_trusted_stored_request(), + "empty canonical demand should fan out to same-profile PBS plans" + ); + } + + #[test] + fn malformed_envelopes_are_atomic_and_do_not_trigger_stored_routes() { + let too_many = Value::Object( + (0..=MAX_BIDDER_ENTRIES) + .map(|index| (format!("bidder-{index}"), json!({"placement": index}))) + .collect(), + ); + let cases = vec![ + ("nonobject envelope", json!("bad")), + ("nonobject params", envelope(Some(json!(true)))), + ("invalid key", envelope(Some(json!({" bad": {"x": 1}})))), + ( + "reserved key", + envelope(Some(json!({"trustedServer": {"x": 1}}))), + ), + ("empty value", envelope(Some(json!({"alpha": {}})))), + ("nonobject value", envelope(Some(json!({"alpha": 1})))), + ( + "partial", + envelope(Some(json!({"alpha": {"x": 1}, "beta": null}))), + ), + ( + "unknown field", + json!({"bidderParams": {"alpha": {"x": 1}}, "endpoint": "https://bad.example"}), + ), + ("too many bidders", envelope(Some(too_many))), + ( + "oversized zone", + json!({"bidderParams": {}, "zone": "z".repeat(MAX_PREBID_ZONE_BYTES + 1)}), + ), + ("nonstring zone", json!({"bidderParams": {}, "zone": 1})), + ]; + for (name, malformed) in cases { + let bidders = HashMap::from([ + (TRUSTED_SERVER_ENVELOPE.to_string(), malformed), + ("beta".to_string(), json!({"placement": "direct"})), + ]); + let routed = route_auction(request(vec![slot(bidders)]), &inbound(), &plan(), None); + assert_eq!( + routed.diagnostics().malformed_envelope_count(), + 1, + "{name} should record one malformed envelope" + ); + assert!( + routed.inputs().iter().all(|provider| { + provider.provider_id().as_str() != "pbs-a" + && provider.provider_id().as_str() != "pbs-b" + }), + "{name} should not produce stored or inline PBS demand" + ); + assert_eq!( + input(&routed, "standard-direct").slots()[0] + .bidder_params() + .len(), + 1, + "{name} should preserve independent valid direct demand" + ); + assert!( + routed + .inputs() + .iter() + .any(|provider| provider.provider_id().as_str() == "aps-primary"), + "{name} should preserve independent all-eligible participation" + ); + } + } + + #[test] + fn exact_envelope_bidder_entry_bound_is_accepted_and_next_entry_is_rejected() { + let accepted = Value::Object( + (0..MAX_BIDDER_ENTRIES) + .map(|index| (format!("bidder-{index}"), json!({"placement": index}))) + .collect(), + ); + let rejected = Value::Object( + (0..=MAX_BIDDER_ENTRIES) + .map(|index| (format!("bidder-{index}"), json!({"placement": index}))) + .collect(), + ); + assert!( + normalize_envelope(&envelope(Some(accepted))).is_some(), + "exactly 128 envelope bidder entries should be admitted" + ); + assert!( + normalize_envelope(&envelope(Some(rejected))).is_none(), + "129 envelope bidder entries should be rejected" + ); + } + + #[test] + fn unknown_bidder_is_counted_without_fallback() { + let routed = route_auction( + request(vec![slot(HashMap::from([( + TRUSTED_SERVER_ENVELOPE.to_string(), + envelope(Some(json!({"unknown": {"placement": 1}}))), + )]))]), + &inbound(), + &plan(), + None, + ); + assert_eq!( + routed.diagnostics().unroutable_bidder_count(), + 1, + "unknown bidder should increment bounded diagnostics" + ); + assert_eq!( + routed.inputs().len(), + 1, + "only all-eligible APS should remain" + ); + assert_eq!( + routed.inputs()[0].provider_id().as_str(), + "aps-primary", + "unknown demand should not cause PBS fallback" + ); + } + + #[test] + fn direct_usable_params_win_collision_and_unusable_direct_does_not_overwrite() { + let cases = [ + ("usable direct", json!({"source": "direct"}), "direct", 0), + ("empty direct", json!({}), "envelope", 1), + ("null direct", Value::Null, "envelope", 1), + ]; + for (name, direct, expected_source, malformed_count) in cases { + let routed = route_auction( + request(vec![slot(HashMap::from([ + ( + TRUSTED_SERVER_ENVELOPE.to_string(), + envelope(Some(json!({"alpha": {"source": "envelope"}}))), + ), + ("alpha".to_string(), direct), + ]))]), + &inbound(), + &plan(), + None, + ); + assert_eq!( + input(&routed, "pbs-a").slots()[0].bidder_params() + [&BidderId::from_str("alpha").expect("should parse bidder")]["source"], + expected_source, + "{name} should follow deterministic collision semantics" + ); + assert_eq!( + routed.diagnostics().malformed_direct_demand_count(), + malformed_count, + "{name} should record only unusable direct demand" + ); + } + } + + #[test] + fn hash_map_insertion_order_does_not_change_routing() { + let entries = [ + ("alpha".to_string(), json!({"a": 1})), + ("unknown".to_string(), json!({"u": 1})), + ( + TRUSTED_SERVER_ENVELOPE.to_string(), + envelope(Some(json!({"alpha": {"a": 0}}))), + ), + ]; + let forward = HashMap::from(entries.clone()); + let reverse = entries.into_iter().rev().collect::>(); + let first = route_auction(request(vec![slot(forward)]), &inbound(), &plan(), None); + let second = route_auction(request(vec![slot(reverse)]), &inbound(), &plan(), None); + let summarize = |routed: &RoutedAuction| { + routed + .inputs() + .iter() + .map(|provider| { + ( + provider.provider_id().as_str().to_string(), + provider.slots()[0] + .bidder_params() + .keys() + .map(|bidder| bidder.as_str().to_string()) + .collect::>(), + ) + }) + .collect::>() + }; + assert_eq!(summarize(&first), summarize(&second)); + assert_eq!(first.diagnostics(), second.diagnostics()); + } + + #[test] + fn mixed_routing_filters_params_per_provider_and_inline_wins_over_stored() { + let routed = route_auction( + request(vec![slot(HashMap::from([ + ( + TRUSTED_SERVER_ENVELOPE.to_string(), + json!({"zone": "home", "bidderParams": null}), + ), + ("alpha".to_string(), json!({"placement": "pbs"})), + ("beta".to_string(), json!({"placement": "direct"})), + ]))]), + &inbound(), + &plan(), + None, + ); + let ids = routed + .inputs() + .iter() + .map(|provider| provider.provider_id().as_str()) + .collect::>(); + assert_eq!( + ids, + vec!["aps-primary", "pbs-a", "pbs-b", "standard-direct"], + "inputs should follow deterministic provider-ID order" + ); + let aps = input(&routed, "aps-primary") + .slots() + .first() + .expect("should have slot"); + assert!( + aps.bidder_params().is_empty(), + "APS must receive no foreign params" + ); + assert_eq!(aps.prebid_zone(), None, "APS must receive no Prebid zone"); + let pbs_a = &input(&routed, "pbs-a").slots()[0]; + assert_eq!(pbs_a.bidder_params().len(), 1); + assert!( + !pbs_a.has_trusted_stored_request(), + "inline params should win for this PBS provider" + ); + assert_eq!(pbs_a.prebid_zone(), Some("home")); + let pbs_b = &input(&routed, "pbs-b").slots()[0]; + assert!(pbs_b.bidder_params().is_empty()); + assert!(pbs_b.has_trusted_stored_request()); + let direct = &input(&routed, "standard-direct").slots()[0]; + assert_eq!(direct.bidder_params().len(), 1); + assert!(direct.prebid_zone().is_none()); + for provider in routed.inputs() { + assert!(provider.common_request().slots.is_empty()); + assert!(provider.slots()[0].slot().bidders.is_empty()); + } + } + + #[test] + fn nonbanner_and_zero_sized_formats_are_removed_and_empty_slots_are_omitted() { + let mut mixed = slot(HashMap::new()); + mixed.formats = vec![ + AdFormat { + media_type: MediaType::Video, + width: 640, + height: 360, + }, + AdFormat { + media_type: MediaType::Banner, + width: 0, + height: 250, + }, + AdFormat { + media_type: MediaType::Banner, + width: u32::MAX, + height: 250, + }, + AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }, + ]; + let mut invalid = slot(HashMap::new()); + invalid.id = "invalid".to_string(); + invalid.formats = vec![ + AdFormat { + media_type: MediaType::Native, + width: 1, + height: 1, + }, + AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 0, + }, + ]; + let routed = route_auction(request(vec![mixed, invalid]), &inbound(), &plan(), None); + assert_eq!( + routed.inputs().len(), + 3, + "APS and two PBS providers should receive the eligible slot" + ); + for provider in routed.inputs() { + assert_eq!(provider.slots().len(), 1); + assert_eq!(provider.slots()[0].slot().id, "slot-1"); + assert_eq!(provider.slots()[0].slot().formats.len(), 1); + assert_eq!(provider.slots()[0].slot().formats[0].width, 300); + } + let none = route_auction( + request(vec![slot_with_formats(vec![AdFormat { + media_type: MediaType::Video, + width: 640, + height: 360, + }])]), + &inbound(), + &plan(), + None, + ); + assert!( + none.inputs().is_empty(), + "no eligible slots should omit every provider input" + ); + assert_eq!( + none.skipped_no_eligible_provider_ids() + .iter() + .map(ProviderId::as_str) + .collect::>(), + vec!["aps-primary", "pbs-a", "pbs-b", "standard-direct"], + "no-banner auction should retain every provider's deterministic skip outcome" + ); + } + + #[test] + fn explicit_no_demand_retains_deterministic_skip_outcomes() { + let routed = route_auction( + request(vec![slot(HashMap::from([( + "unknown".to_string(), + json!({"placement": "none"}), + )]))]), + &inbound(), + &plan(), + None, + ); + assert_eq!( + routed + .skipped_no_eligible_provider_ids() + .iter() + .map(ProviderId::as_str) + .collect::>(), + vec!["pbs-a", "pbs-b", "standard-direct"], + "explicit providers with no routed demand should be retained as skipped" + ); + } + + #[test] + fn trusted_routes_admit_explicit_aps_and_standard_and_ignore_unknown_provider() { + let trusted_routes = TrustedProviderRoutes::new(vec![vec![ + ProviderId::from_str("aps-primary").expect("should parse provider"), + ProviderId::from_str("standard-direct").expect("should parse provider"), + ProviderId::from_str("unknown-provider").expect("should parse provider"), + ]]); + let routed = route_auction_with_trusted_routes( + request(vec![slot(HashMap::from([( + "unknown".to_string(), + json!({"placement": "none"}), + )]))]), + &inbound(), + &explicit_plan(), + None, + &trusted_routes, + ); + assert_eq!( + routed + .inputs() + .iter() + .map(|input| input.provider_id().as_str()) + .collect::>(), + vec!["aps-primary", "standard-direct"], + "only known server-owned provider routes should admit explicit providers" + ); + assert_eq!( + routed.diagnostics().unroutable_trusted_provider_count(), + 1, + "unknown trusted provider should be ignored and counted" + ); + assert_eq!( + routed + .skipped_no_eligible_provider_ids() + .iter() + .map(ProviderId::as_str) + .collect::>(), + vec!["pbs-a", "pbs-b"], + "unrouted explicit providers should retain skip outcomes" + ); + } + + fn slot_with_formats(formats: Vec) -> AdSlot { + let mut value = slot(HashMap::new()); + value.formats = formats; + value + } + + #[test] + fn snapshots_first_headers_retains_raw_bytes_and_ignores_inbound_xff() { + let mut inbound = inbound(); + inbound + .headers_mut() + .append(header::COOKIE, HeaderValue::from_static("first=1")); + inbound + .headers_mut() + .append(header::COOKIE, HeaderValue::from_static("second=2")); + inbound.headers_mut().append( + header::USER_AGENT, + HeaderValue::from_bytes(b"agent-\x80").expect("should accept raw header"), + ); + inbound.headers_mut().append( + header::REFERER, + HeaderValue::from_static("https://publisher.example.test/article"), + ); + inbound + .headers_mut() + .append(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US")); + inbound.headers_mut().append( + HeaderName::from_static("x-forwarded-for"), + HeaderValue::from_static("203.0.113.250"), + ); + inbound.headers_mut().append( + HeaderName::from_static("dnt"), + HeaderValue::from_static(" 1 "), + ); + let attested = IpAddr::from_str("192.0.2.10").expect("should parse IP"); + let routed = route_auction( + request(vec![slot(HashMap::new())]), + &inbound, + &plan(), + Some(attested), + ); + let headers = routed.prebid_transport_headers(); + assert_eq!(headers.cookie(), Some(&HeaderValue::from_static("first=1"))); + assert_eq!( + headers.user_agent().expect("should retain UA").as_bytes(), + b"agent-\x80" + ); + assert_eq!( + headers.referer(), + Some(&HeaderValue::from_static( + "https://publisher.example.test/article" + )) + ); + assert_eq!( + headers.accept_language(), + Some(&HeaderValue::from_static("en-US")) + ); + assert_eq!(routed.attested_client_ip(), Some(attested)); + assert_eq!(routed.dnt(), Some(true)); + for provider in routed.inputs() { + let expected_timeout = match provider.provider_id().as_str() { + "aps-primary" => 800, + id if id.starts_with("pbs-") => 1000, + _ => 900, + }; + assert_eq!(provider.timeout_ms(), expected_timeout); + } + } +} diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index b3e049eaf..029e7734a 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -770,7 +770,11 @@ fn bid_row( row.slot_w = Some(u16::try_from(bid.width).unwrap_or(u16::MAX)); row.slot_h = Some(u16::try_from(bid.height).unwrap_or(u16::MAX)); row.media_type = media_type_for_slot(request, &bid.slot_id).map(str::to_owned); - row.seat = Some(bid.bidder.clone()); + row.seat = Some( + bid.returned_seat + .clone() + .unwrap_or_else(|| bid.bidder.clone()), + ); row.price_cpm = price; row.currency = Some(bid.currency.clone()); row.is_win = Some(is_win); @@ -974,6 +978,7 @@ mod tests { creative: None, adomain: Some(vec!["advertiser.example".to_owned()]), bidder: bidder.to_owned(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -1100,6 +1105,112 @@ mod tests { ); } + #[test] + fn bid_rows_prefer_returned_seat_over_delivery_bidder() { + let request = test_request("ts-ec-derived-id"); + let mut aps_bid = bid("slot-1", "aps", Some("ad-1"), Some(1.25)); + aps_bid.returned_seat = Some("upstream-seat".to_string()); + let provider = AuctionResponse::success("aps-primary", vec![aps_bid.clone()], 12); + let result = OrchestrationResult { + provider_responses: vec![provider], + mediator_response: None, + winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), + total_time_ms: 12, + metadata: HashMap::new(), + }; + let batch = build_auction_events( + AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/auction", 1), + AuctionTerminalOutcome::Completed { + request: &request, + result: &result, + delivered_winner_slots: None, + }, + ); + + let provider_row = batch + .rows() + .iter() + .find(|row| row.event_kind == "provider_call") + .expect("should emit provider row"); + assert_eq!(provider_row.provider.as_deref(), Some("aps-primary")); + let bid_row = batch + .rows() + .iter() + .find(|row| row.event_kind == "bid") + .expect("should emit bid row"); + assert_eq!(bid_row.provider.as_deref(), Some("aps-primary")); + assert_eq!(bid_row.seat.as_deref(), Some("upstream-seat")); + + let mut fallback_bid = aps_bid; + fallback_bid.returned_seat = None; + let fallback = OrchestrationResult { + provider_responses: vec![AuctionResponse::success( + "aps-primary", + vec![fallback_bid.clone()], + 12, + )], + mediator_response: None, + winning_bids: HashMap::from([("slot-1".to_owned(), fallback_bid)]), + total_time_ms: 12, + metadata: HashMap::new(), + }; + let fallback_batch = build_auction_events( + AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/auction", 1), + AuctionTerminalOutcome::Completed { + request: &request, + result: &fallback, + delivered_winner_slots: None, + }, + ); + assert_eq!( + fallback_batch + .rows() + .iter() + .find(|row| row.event_kind == "bid") + .and_then(|row| row.seat.as_deref()), + Some("aps") + ); + } + + #[test] + fn mediated_aps_telemetry_retains_provider_upstream_seat_and_delivery_identity() { + let request = test_request("ts-ec-derived-id"); + let mut aps_bid = bid("slot-1", "aps", Some("ad-1"), Some(1.25)); + aps_bid.returned_seat = Some("upstream-seat".to_string()); + let provider = AuctionResponse::success("aps-primary", vec![aps_bid.clone()], 12); + let mediator = AuctionResponse::success("adserver_mock", vec![aps_bid.clone()], 3); + let result = OrchestrationResult { + provider_responses: vec![provider], + mediator_response: Some(mediator), + winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), + total_time_ms: 15, + metadata: HashMap::new(), + }; + let batch = build_auction_events( + AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/auction", 1), + AuctionTerminalOutcome::Completed { + request: &request, + result: &result, + delivered_winner_slots: None, + }, + ); + + let provider_row = batch + .rows() + .iter() + .find(|row| row.event_kind == "provider_call") + .expect("should emit provider call"); + assert_eq!(provider_row.provider.as_deref(), Some("aps-primary")); + let bid_row = batch + .rows() + .iter() + .find(|row| row.event_kind == "bid") + .expect("should emit provider bid"); + assert_eq!(bid_row.provider.as_deref(), Some("aps-primary")); + assert_eq!(bid_row.seat.as_deref(), Some("upstream-seat")); + assert_eq!(aps_bid.bidder, "aps", "delivery identity remains distinct"); + } + #[test] fn completed_events_do_not_mark_dropped_winners_as_delivered() { let request = test_request("ts-ec-derived-id"); diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index e4b953e05..a83f8899a 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -1,9 +1,17 @@ +use std::collections::HashMap; use std::sync::LazyLock; use edgezero_core::body::Body as EdgeBody; use http::Request; +use serde_json::json; use super::AuctionContext; +use crate::auction::types::{ + AdFormat, AdSlot, AuctionRequest, DeviceInfo, MediaType, PublisherInfo, UserInfo, +}; +use crate::consent::ConsentContext; +use crate::geo::GeoInfo; +use crate::openrtb::{Eid, Uid}; use crate::platform::{RuntimeServices, test_support::noop_services}; use crate::settings::Settings; @@ -19,7 +27,95 @@ pub(crate) fn create_test_auction_context<'a>( settings, request, timeout_ms, + transport_timeout_ms: timeout_ms, provider_responses: None, services, } } + +/// Build canonical request facts shared by the PBS and APS Stage 1 wire goldens. +/// +/// The supported and unsupported formats deliberately exercise each profile's +/// existing filtering and field-ownership policy. `trustedServer` bidder +/// parameters are included to pin that PBS consumes them while APS ignores +/// them. +pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { + AuctionRequest { + id: "fictional-auction".to_string(), + slots: vec![AdSlot { + id: "fictional-slot".to_string(), + formats: vec![ + AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }, + AdFormat { + media_type: MediaType::Video, + width: 640, + height: 480, + }, + AdFormat { + media_type: MediaType::Banner, + width: u32::MAX, + height: 90, + }, + AdFormat { + media_type: MediaType::Banner, + width: 728, + height: 90, + }, + ], + floor_price: Some(1.0), + targeting: HashMap::new(), + bidders: HashMap::from([( + "trustedServer".to_string(), + json!({ + "bidderParams": { + "exampleBidder": { "placement": "fictional-placement" } + } + }), + )]), + }], + publisher: PublisherInfo { + domain: "publisher.example".to_string(), + page_url: Some("https://publisher.example/article".to_string()), + }, + user: UserInfo { + id: Some("fictional-user".to_string()), + consent: Some(ConsentContext { + gdpr_applies: true, + raw_tc_string: Some("fictional-tcf".to_string()), + raw_us_privacy: Some("1YNN".to_string()), + raw_gpp_string: Some("fictional-gpp".to_string()), + gpp_section_ids: Some(vec![2, 6]), + raw_ac_string: Some("fictional-ac".to_string()), + ..Default::default() + }), + eids: Some(vec![Eid { + source: "identity.example".to_string(), + uids: vec![Uid { + id: "fictional-uid".to_string(), + atype: Some(1), + ext: None, + }], + }]), + }, + device: Some(DeviceInfo { + user_agent: Some("Fictional Browser".to_string()), + ip: Some("192.0.2.10".to_string()), + geo: Some(GeoInfo { + city: "Example City".to_string(), + country: "US".to_string(), + continent: "NA".to_string(), + latitude: 12.34, + longitude: 56.78, + metro_code: 501, + region: Some("CA".to_string()), + asn: None, + }), + }), + site: None, + context: HashMap::new(), + } +} diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index f61334787..406915706 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -146,7 +146,14 @@ pub struct SiteInfo { pub struct AuctionContext<'a> { pub settings: &'a Settings, pub request: &'a Request, + /// Exact logical provider budget used by auction policy and payloads. pub timeout_ms: u32, + /// Canonical backend transport timeout used for provider registration. + /// + /// This can be lower than `timeout_ms` on runtimes whose backend names + /// encode timers. Providers that register a backend should use this value + /// for transport timers while retaining `timeout_ms` for logical policy. + pub transport_timeout_ms: u32, /// Provider responses from the bidding phase, used by mediators. /// This is `None` for regular bidders and `Some` when calling a mediator. pub provider_responses: Option<&'a [AuctionResponse]>, @@ -243,8 +250,11 @@ pub struct Bid { pub creative: Option, /// Advertiser domain pub adomain: Option>, - /// Bidder/seat identifier + /// Browser-facing delivery bidder code. pub bidder: String, + /// Exact valid upstream `seatbid.seat`, independent of delivery identity. + #[serde(skip)] + pub returned_seat: Option, /// Width of creative pub width: u32, /// Height of creative @@ -409,6 +419,7 @@ mod tests { creative: None, adomain: None, bidder: bidder.to_owned(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -532,6 +543,20 @@ mod tests { ); } + #[test] + fn returned_seat_is_internal_and_not_serialized() { + let mut bid = make_bid("aps"); + bid.returned_seat = Some("upstream-seat".to_string()); + + let serialized = serde_json::to_value(&bid).expect("should serialize bid"); + assert!( + serialized.get("returned_seat").is_none(), + "returned seat must not change client/debug wire shapes" + ); + let decoded: Bid = serde_json::from_value(serialized).expect("should deserialize bid"); + assert!(decoded.returned_seat.is_none()); + } + #[test] fn bid_with_cache_fields_round_trips_through_json() { let bid = Bid { @@ -541,6 +566,7 @@ mod tests { creative: None, adomain: None, bidder: "thetradedesk".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -647,6 +673,7 @@ mod tests { creative: None, adomain: None, bidder: "kargo".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index c6c237e59..ec469ef82 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -1,7 +1,11 @@ //! Auction configuration types (separated to avoid circular deps in build.rs). use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; + +pub use crate::auction::plan::{ + BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, +}; /// Auction orchestration configuration. #[derive(Debug, Clone, Deserialize, Serialize)] @@ -23,12 +27,15 @@ pub struct AuctionConfig { )] pub rewrite_creatives: bool, - /// Provider names that participate in bidding - /// Simply list the provider names (e.g., ["prebid", "aps"]) - #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] - pub providers: Vec, + /// Operator-defined bidder-provider instances, keyed by provider ID. + #[serde(default)] + pub providers: BTreeMap, + + /// Client-visible bidder routes, keyed by bidder code. + #[serde(default)] + pub bidders: BTreeMap, - /// Optional mediator provider name (e.g., "gam") + /// Optional separately registered mediator provider name. /// When set, runs parallel mediation strategy (bidders in parallel, then mediator decides) /// When omitted, runs parallel only strategy (bidders in parallel, highest CPM wins) pub mediator: Option, @@ -54,7 +61,8 @@ impl Default for AuctionConfig { Self { enabled: false, rewrite_creatives: default_rewrite_creatives(), - providers: Vec::new(), + providers: BTreeMap::new(), + bidders: BTreeMap::new(), mediator: None, timeout_ms: default_timeout(), creative_store: default_creative_store(), @@ -89,10 +97,26 @@ fn default_allowed_context_keys() -> HashSet { reason = "methods are used by the runtime crate but not by build.rs path inclusion" )] impl AuctionConfig { - /// Get all provider names. - #[must_use] - pub fn provider_names(&self) -> &[String] { - &self.providers + #[cfg(test)] + pub(crate) fn legacy_provider_map(names: &[&str]) -> BTreeMap { + names + .iter() + .map(|name| { + let id = ProviderId::unchecked_for_legacy_test(name); + ( + id, + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "standard".to_string(), + endpoint: format!("https://{name}.example/openrtb2/auction"), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({}), + }, + ) + }) + .collect() } /// Check if this config has a mediator configured. @@ -142,4 +166,37 @@ mod tests { "should preserve an explicit rewrite opt-out" ); } + + #[test] + fn provider_list_shape_is_rejected() { + let error = serde_json::from_value::(serde_json::json!({ + "providers": ["prebid"] + })) + .expect_err("should reject the removed provider-list schema"); + + assert!( + error.to_string().contains("map") || error.to_string().contains("object"), + "should require map-shaped providers: {error}" + ); + } + + #[test] + fn map_schema_round_trips_provider_and_bidder_routes() { + let config: AuctionConfig = serde_json::from_value(serde_json::json!({ + "providers": { + "pbs-main": { + "protocol": "openrtb-2.6", + "profile": "prebid-server", + "endpoint": "https://prebid.example/openrtb2/auction" + } + }, + "bidders": { + "example-bidder": { "provider": "pbs-main" } + } + })) + .expect("should parse map-shaped auction config"); + + assert_eq!(config.providers.len(), 1); + assert_eq!(config.bidders.len(), 1); + } } diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index cdee3b222..a798c2ba6 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -7,7 +7,6 @@ //! `EdgeZero`'s typed config push path. use std::borrow::Cow; -use std::collections::HashSet; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -127,26 +126,16 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { /// Returns [`TrustedServerError`] when the config should not be deployed. pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report> { settings.reject_placeholder_secrets()?; - let enabled_auction_providers = validate_enabled_integrations(settings)?; - validate_auction_provider_names(settings, &enabled_auction_providers)?; + validate_enabled_integrations(settings)?; + crate::auction::compile_auction_plan(settings)?; PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; Ok(()) } -fn validate_enabled_integrations( - settings: &Settings, -) -> Result, Report> { - let mut enabled_auction_providers = HashSet::new(); - - if validate_prebid(settings)? { - enabled_auction_providers.insert("prebid"); - } - if validate_integration::(settings, "aps")? { - enabled_auction_providers.insert("aps"); - } - if validate_integration::(settings, "adserver_mock")? { - enabled_auction_providers.insert("adserver_mock"); - } +fn validate_enabled_integrations(settings: &Settings) -> Result<(), Report> { + validate_prebid(settings)?; + validate_integration::(settings, "aps")?; + validate_integration::(settings, "adserver_mock")?; validate_integration::(settings, "testlight")?; validate_integration::(settings, "nextjs")?; validate_integration::(settings, "permutive")?; @@ -159,11 +148,15 @@ fn validate_enabled_integrations( validate_integration::(settings, "gpt")?; validate_integration::(settings, "gpt_diagnostics")?; - Ok(enabled_auction_providers) + Ok(()) } -fn validate_prebid(settings: &Settings) -> Result> { - prebid::validate_config_for_startup(settings).map(|config| config.is_some()) +fn validate_prebid(settings: &Settings) -> Result<(), Report> { + let Some(config) = settings.integration_config::("prebid")? + else { + return Ok(()); + }; + prebid::validate_browser_config_for_startup(&config, &settings.proxy.allowed_domains) } fn validate_integration( @@ -178,32 +171,6 @@ where .map(|config| config.is_some()) } -fn validate_auction_provider_names( - settings: &Settings, - enabled_auction_providers: &HashSet<&'static str>, -) -> Result<(), Report> { - if !settings.auction.enabled { - return Ok(()); - } - - for provider_name in settings - .auction - .providers - .iter() - .chain(settings.auction.mediator.iter()) - { - if !enabled_auction_providers.contains(provider_name.as_str()) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "auction provider `{provider_name}` is listed in [auction] but no enabled integration provides it" - ), - })); - } - } - - Ok(()) -} - fn report_to_validation_errors(report: &Report) -> ValidationErrors { let mut error = ValidationError::new("trusted_server_deploy_validation"); error.message = Some(Cow::Owned(report.to_string())); @@ -215,6 +182,8 @@ fn report_to_validation_errors(report: &Report) -> Validatio #[cfg(test)] mod tests { + use std::collections::HashSet; + use super::*; use crate::test_support::tests::crate_test_settings_str; @@ -369,6 +338,33 @@ password = "production-admin-password-32-bytes" ); } + #[test] + fn deploy_validation_requires_external_bundle_url_for_enabled_prebid() { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "prebid", + &serde_json::json!({ + "enabled": true, + "bundle": { "adapters": ["exampleBidder"] } + }), + ) + .expect("should insert enabled Prebid config"); + + let error = validate_settings_for_deploy(&settings) + .expect_err("should require enabled Prebid external bundle URL"); + assert!(error.to_string().contains("external_bundle_url")); + } + + #[test] + fn deploy_validation_accepts_typed_prebid_bundle_build_table() { + let settings = valid_settings(); + + validate_settings_for_deploy(&settings) + .expect("test config with typed Prebid bundle build table should validate"); + } + #[test] fn deploy_validation_covers_registered_integration_builders() { let validated_ids: HashSet<&'static str> = @@ -408,7 +404,15 @@ password = "production-admin-password-32-bytes" fn validate_trait_reports_deploy_errors() { let mut settings = valid_settings(); settings.auction.enabled = true; - settings.auction.providers = vec!["missing-provider".to_string()]; + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&["missing-provider"]); + settings + .auction + .providers + .values_mut() + .next() + .expect("should have provider") + .protocol = "unsupported".to_string(); let app_config = TrustedServerAppConfig { settings }; let err = app_config diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..d642f82c2 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -46,27 +46,6 @@ mod tests { use super::*; use crate::redacted::Redacted; use crate::test_support::tests::crate_test_settings_str; - use serde::Deserialize; - - // Intentionally mirrors `AuctionConfig` before `rewrite_creatives` existed. - // Do not add fields introduced after that snapshot: this test proves a - // default payload remains readable by the previous binary schema. - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct LegacyAuctionConfig { - #[serde(rename = "enabled")] - _enabled: bool, - #[serde(rename = "providers")] - _providers: Vec, - #[serde(rename = "mediator")] - _mediator: Option, - #[serde(rename = "timeout_ms")] - _timeout_ms: u32, - #[serde(rename = "creative_store")] - _creative_store: String, - #[serde(rename = "allowed_context_keys")] - _allowed_context_keys: std::collections::HashSet, - } fn test_settings() -> Settings { Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") @@ -123,19 +102,6 @@ mod tests { ); } - #[test] - fn default_auction_payload_is_accepted_by_legacy_schema() { - let data = - serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); - let auction = data - .get("auction") - .cloned() - .expect("should serialize auction settings"); - - serde_json::from_value::(auction) - .expect("should deserialize the default payload with the legacy schema"); - } - #[test] fn disabled_rewrite_creatives_survives_blob_round_trip() { let mut original = test_settings(); diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index e44b0cbcf..3e6c068b7 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -708,23 +708,16 @@ impl CreativeOpportunitySlot { /// Converts this slot into an [`AdSlot`] ready for use in an auction request. /// /// Prebid Server bidder params are wired into the `bidders` map keyed by - /// bidder name. Legacy APS slot params are accepted in configuration but - /// intentionally ignored by the APS `OpenRTB` provider. - /// - /// When [`PrebidSlotParams::bidders`] is empty, a `trustedServer` entry is - /// injected so [`PrebidAuctionProvider`] expands all `config.bidders` - /// automatically. The slot's `targeting.zone` value is forwarded as - /// `trustedServer.zone` so zone-aware bid-param override rules fire correctly. + /// bidder name. APS slot params are ignored by the generic APS profile. + /// When [`PrebidSlotParams::bidders`] is empty, a `trustedServer` marker is + /// inserted for stored-request compatibility and carries the optional zone. #[must_use] pub fn to_ad_slot(&self) -> AdSlot { let mut bidders: HashMap = HashMap::new(); if let Some(ref prebid) = self.providers.prebid { if prebid.bidders.is_empty() { - // No explicit per-bidder override: let the Prebid provider expand - // all config.bidders. The "trustedServer" key triggers - // expand_trusted_server_bidders in PrebidAuctionProvider, giving - // each bidder an empty params object that the override engine then - // fills with zone-aware rules. + // No explicit per-bidder params: preserve the stored-request + // marker and carry the zone for profile routing. let mut ts = serde_json::json!({ "bidderParams": {} }); if let Some(zone) = self.targeting.get("zone") { ts["zone"] = serde_json::Value::String(zone.clone()); @@ -814,17 +807,14 @@ pub struct ApsSlotParams { /// Inline Prebid Server bidder parameters for a slot. /// -/// When `bidders` is empty, `to_ad_slot` injects a `trustedServer` entry so -/// [`PrebidAuctionProvider`] expands all `config.bidders` automatically. -/// When `bidders` is non-empty the map is forwarded verbatim, bypassing -/// automatic expansion (useful for slots that need explicit per-bidder params). +/// When `bidders` is empty, `to_ad_slot` injects a `trustedServer` stored-request +/// marker. When non-empty, the bidder map is forwarded verbatim. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct PrebidSlotParams { /// Per-bidder inline params map. Bidder name → params object. /// - /// Leave empty (or omit `bidders` in config) to auto-expand all - /// `config.bidders` with zone-aware param overrides. + /// Leave empty (or omit `bidders` in config) to use the stored-request path. /// /// Note: when this map is non-empty it is forwarded verbatim, so a slot's /// `targeting.zone` is **not** injected for these bidders (the `trustedServer` @@ -868,7 +858,18 @@ pub fn match_slots<'a>( #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use std::str::FromStr as _; + + use edgezero_core::body::Body as EdgeBody; + use http::Request; + use super::*; + use crate::auction::plan::{ + AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, + }; + use crate::auction::routing::route_auction; + use crate::auction::types::{AuctionRequest, PublisherInfo, UserInfo}; fn make_slot(id: &str, patterns: Vec<&str>) -> CreativeOpportunitySlot { CreativeOpportunitySlot { @@ -1743,6 +1744,69 @@ mod tests { ); } + #[test] + fn creative_opportunity_canonical_slot_feeds_shared_stored_router_with_zone() { + let mut slot = make_slot("header", vec!["/"]); + slot.targeting + .insert("zone".to_string(), "header".to_string()); + slot.providers.prebid = Some(PrebidSlotParams { + bidders: HashMap::new(), + }); + let plan = AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 900, + providers: BTreeMap::from([( + ProviderId::from_str("pbs-primary").expect("should parse provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "prebid-server".to_string(), + endpoint: "https://pbs.example.test/openrtb".to_string(), + timeout_ms: None, + routing: RoutingMode::Explicit, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({}), + }, + )]), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, + }) + .expect("should compile plan"); + let auction_request = AuctionRequest { + id: "auction-1".to_string(), + slots: vec![slot.to_ad_slot()], + publisher: PublisherInfo { + domain: "publisher.example.test".to_string(), + page_url: None, + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + }; + let inbound = Request::builder() + .uri("https://publisher.example.test/") + .body(EdgeBody::empty()) + .expect("should build request"); + let routed = route_auction(auction_request, &inbound, &plan, None); + + assert_eq!(routed.inputs().len(), 1); + assert!(routed.inputs()[0].slots()[0].has_trusted_stored_request()); + assert_eq!(routed.inputs()[0].slots()[0].prebid_zone(), Some("header")); + assert!( + routed.inputs()[0].slots().iter().all(|slot| { + !slot + .bidder_params() + .keys() + .any(|bidder| bidder.as_str() == "pbs-primary") + }), + "provider ID should not reach client-controlled bidder input" + ); + } + #[test] fn to_ad_slot_injects_trusted_server_without_zone_when_targeting_absent() { let mut slot = make_slot("no-zone", vec!["/"]); diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 889234b56..cfae85730 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -839,8 +839,14 @@ mod tests { crate::integrations::gpt_diagnostics::prepare_request(&settings, &mut request) .expect("should prepare diagnostics request"); let mut config = create_test_config(); - config.integrations = - IntegrationRegistry::new(&settings).expect("should build integration registry"); + config.integrations = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should build integration registry"); config.gpt_diagnostics = Some(decision); let processor = create_html_processor(config); @@ -936,7 +942,14 @@ mod tests { #[test] fn test_html_processor_config_from_settings() { let settings = create_test_settings(); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = HtmlProcessorConfig::from_settings( &settings, ®istry, @@ -1048,7 +1061,14 @@ mod tests { ) .expect("should insert testlight config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let mut config = create_test_config(); config.integrations = registry; diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index ba3f82776..b639946e6 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -312,6 +312,10 @@ impl AdServerMockProvider { width, height, bidder: restored_bidder, + returned_seat: original.map_or_else( + || (seat_name != "unknown").then(|| seat_name.to_string()), + |bid| bid.returned_seat.clone(), + ), adomain: bid["adomain"].as_array().map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) @@ -398,7 +402,7 @@ impl AdServerMockProvider { #[async_trait(?Send)] impl AuctionProvider for AdServerMockProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "adserver_mock" } @@ -465,14 +469,14 @@ impl AuctionProvider for AdServerMockProvider { } } - // Uses context.timeout_ms (auction-scoped) rather than the 15 s fixed - // timeout in ensure_integration_backend, which is for proxy endpoints. - // Send async with auction-scoped timeout + // Uses the auction-scoped canonical transport timeout rather than the + // 15 s fixed timeout in ensure_integration_backend, which is for proxy + // endpoints. The exact logical budget remains in context.timeout_ms. let backend_name = ensure_integration_backend_with_timeout( context.services, &self.config.endpoint, "adserver_mock", - Duration::from_millis(u64::from(context.timeout_ms)), + Duration::from_millis(u64::from(context.transport_timeout_ms)), ) .change_context(TrustedServerError::Auction { message: format!( @@ -534,12 +538,16 @@ impl AuctionProvider for AdServerMockProvider { self.config.enabled } - fn backend_name(&self, services: &RuntimeServices, timeout_ms: u32) -> Option { + fn backend_name( + &self, + services: &RuntimeServices, + transport_timeout_ms: u32, + ) -> Option { predict_integration_backend_name( services, &self.config.endpoint, "adserver_mock", - Duration::from_millis(u64::from(timeout_ms)), + Duration::from_millis(u64::from(transport_timeout_ms)), ) .inspect_err(|e| { log::error!( @@ -637,6 +645,7 @@ mod tests { width: 728, height: 90, bidder: "aps".to_string(), + returned_seat: None, adomain: Some(vec!["advertiser.example".to_string()]), nurl: None, burl: None, @@ -686,6 +695,7 @@ mod tests { width: 728, height: 90, bidder: "aps".to_string(), + returned_seat: None, adomain: Some(vec!["advertiser.example".to_string()]), nurl: None, burl: None, @@ -712,6 +722,7 @@ mod tests { width: 728, height: 90, bidder: "test-bidder".to_string(), + returned_seat: None, adomain: None, nurl: Some("https://ssp.example/win?id=mock-bid-001".to_string()), burl: Some("https://ssp.example/bill?id=mock-bid-001".to_string()), @@ -833,6 +844,7 @@ mod tests { creative: Some("
Original Ad
".to_string()), adomain: Some(vec!["example.com".to_string()]), bidder: "mocktioneer".to_string(), + returned_seat: None, width: 728, height: 90, nurl: Some("https://ssp.example/win".to_string()), @@ -905,6 +917,10 @@ mod tests { Some("/cache"), "should restore PBS cache path" ); + assert!( + bid.returned_seat.is_none(), + "a matched original with no returned seat must not inherit mediator seat" + ); } #[test] @@ -941,6 +957,7 @@ mod tests { creative: Some("
Original Ad
".to_string()), adomain: None, bidder: "example-bidder".to_string(), + returned_seat: None, width: 728, height: 90, nurl: None, @@ -1102,6 +1119,7 @@ mod tests { width: 300, height: 250, bidder: "aps".to_string(), + returned_seat: None, adomain: Some(vec!["advertiser.example".to_string()]), nurl: None, burl: None, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4fed9278d..6663c2599 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -1,7 +1,8 @@ //! Amazon Publisher Services (APS/TAM) `OpenRTB` integration. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; +#[cfg(test)] use std::time::Duration; use async_trait::async_trait; @@ -16,34 +17,53 @@ use serde_json::{Value as Json, json}; use url::Url; use validator::{Validate, ValidationError}; +use crate::auction::openrtb::ignored_bidder_params_count; +#[cfg(test)] +use crate::auction::plan::{AuctionPlanConfig, NotificationConfig, ProviderConfig, RoutingMode}; +use crate::auction::profile::ApsProfilePlan; +#[cfg(test)] use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; +use crate::auction::routing::ProviderAuctionInput; +#[cfg(test)] +use crate::auction::types::{AdSlot, AuctionContext, AuctionRequest}; use crate::auction::types::{ - AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, AuctionResponse, Bid, - BidRenderer, MediaType, + ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, MediaType, }; use crate::error::TrustedServerError; use crate::integrations::{ IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded, +}; +#[cfg(test)] +use crate::integrations::{ ensure_integration_backend_with_timeout, predict_integration_backend_name, }; +#[cfg(test)] +use crate::openrtb::ToExt; +#[cfg(test)] use crate::openrtb::{ - Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, ToExt, User, + Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, User, UserExt, to_openrtb_i32, }; -use crate::platform::{PlatformHttpRequest, PlatformResponse, RuntimeServices}; +#[cfg(test)] +use crate::platform::PlatformHttpRequest; +use crate::platform::{PlatformResponse, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const APS_INTEGRATION_ID: &str = "aps"; const APS_RENDERER_ROUTE: &str = "/integrations/aps/renderer"; const DEFAULT_CURRENCY: &str = "USD"; +#[cfg(test)] const APS_SDK_SOURCE: &str = "prebid"; +#[cfg(test)] const APS_SDK_VERSION: &str = "2.2.0"; const MAX_ACCOUNT_ID_BYTES: usize = 1024; const MAX_CREATIVE_ID_BYTES: usize = 1024; const MAX_DEBUG_RESPONSE_PREVIEW_BYTES: usize = 512; const MAX_CREATIVE_URL_BYTES: usize = 4096; +#[cfg(test)] const MAX_LANGUAGE_BYTES: usize = 8; +#[cfg(test)] const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;"; @@ -115,9 +135,10 @@ addEventListener('message',receive); "#; /// Configuration for the APS `OpenRTB` integration. +#[cfg(test)] #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[validate(schema(function = "validate_inventory_identity_override"))] -pub struct ApsConfig { +pub struct LegacyApsProviderConfig { /// Whether APS integration is enabled. #[serde(default = "default_enabled")] pub enabled: bool, @@ -203,6 +224,7 @@ where deserializer.deserialize_any(AccountIdVisitor) } +#[cfg(test)] fn validate_aps_endpoint(value: &str) -> Result<(), ValidationError> { let parsed = Url::parse(value).map_err(|_| ValidationError::new("invalid_aps_endpoint"))?; if parsed.scheme() != "https" @@ -264,12 +286,12 @@ fn validate_inventory_page_origin(value: &str) -> Result<(), ValidationError> { Ok(()) } -fn validate_inventory_identity_override(config: &ApsConfig) -> Result<(), ValidationError> { - let (Some(domain), Some(origin)) = ( - config.inventory_domain.as_deref(), - config.inventory_page_origin.as_deref(), - ) else { - if config.inventory_domain.is_none() && config.inventory_page_origin.is_none() { +fn validate_inventory_identity_override_values( + inventory_domain: Option<&str>, + inventory_page_origin: Option<&str>, +) -> Result<(), ValidationError> { + let (Some(domain), Some(origin)) = (inventory_domain, inventory_page_origin) else { + if inventory_domain.is_none() && inventory_page_origin.is_none() { return Ok(()); } return Err(ValidationError::new( @@ -293,19 +315,33 @@ fn validate_inventory_identity_override(config: &ApsConfig) -> Result<(), Valida Ok(()) } +#[cfg(test)] +fn validate_inventory_identity_override( + config: &LegacyApsProviderConfig, +) -> Result<(), ValidationError> { + validate_inventory_identity_override_values( + config.inventory_domain.as_deref(), + config.inventory_page_origin.as_deref(), + ) +} + +#[cfg(test)] fn default_enabled() -> bool { false } +#[cfg(test)] fn default_endpoint() -> String { "https://web.ads.aps.amazon-adsystem.com/e/pb/bid".to_string() } +#[cfg(test)] fn default_timeout_ms() -> u32 { 800 } -impl Default for ApsConfig { +#[cfg(test)] +impl Default for LegacyApsProviderConfig { fn default() -> Self { Self { enabled: false, @@ -320,20 +356,90 @@ impl Default for ApsConfig { } } +/// Browser integration toggle retained independently from APS server providers. +#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct ApsConfig { + /// Whether browser-side APS integration behavior is enabled. + #[serde(default)] + pub enabled: bool, +} + +#[cfg(test)] +impl IntegrationConfig for LegacyApsProviderConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + impl IntegrationConfig for ApsConfig { fn is_enabled(&self) -> bool { self.enabled } } +/// Typed server-side APS profile configuration used by the auction compiler. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ApsProfileConfig { + #[serde(deserialize_with = "deserialize_account_id")] + pub(crate) account_id: String, + #[serde(default)] + pub(crate) debug: bool, + #[serde(default)] + pub(crate) allow_script_creatives: bool, + #[serde(default)] + pub(crate) inventory_domain: Option, + #[serde(default)] + pub(crate) inventory_page_origin: Option, +} + +/// Parse and validate server-owned APS profile fields without browser enablement. +pub(crate) fn compile_profile_config( + value: serde_json::Value, +) -> Result> { + let profile: ApsProfileConfig = serde_json::from_value(value).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `aps` profile_config: {error}"), + }) + })?; + if let Some(domain) = profile.inventory_domain.as_deref() { + validate_inventory_domain(domain).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `aps` profile_config inventory_domain: {error}"), + }) + })?; + } + if let Some(origin) = profile.inventory_page_origin.as_deref() { + validate_inventory_page_origin(origin).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `aps` profile_config inventory_page_origin: {error}"), + }) + })?; + } + validate_inventory_identity_override_values( + profile.inventory_domain.as_deref(), + profile.inventory_page_origin.as_deref(), + ) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `aps` profile_config inventory identity: {error}"), + }) + })?; + Ok(profile) +} + +#[cfg(test)] #[derive(Debug, Serialize)] struct ApsRequestExt<'a> { account: &'a str, sdk: ApsSdkExt, } +#[cfg(test)] impl ToExt for ApsRequestExt<'_> {} +#[cfg(test)] #[derive(Debug, Serialize)] struct ApsSdkExt { source: &'static str, @@ -350,21 +456,506 @@ struct ApsRendererInput<'a> { height: u32, } -#[derive(Clone)] -struct ApsDebugRequest { +#[derive(Debug, Clone)] +pub(crate) struct ApsDebugRequest { body: String, headers: BTreeMap>, } -/// APS `OpenRTB` auction provider. +impl ApsDebugRequest { + pub(crate) fn capture(body: &[u8], headers: &HeaderMap) -> Self { + Self { + body: String::from_utf8_lossy(body).into_owned(), + headers: aps_debug_headers(headers), + } + } +} + +struct PlannedApsResponsePolicy<'a> { + provider_id: &'a str, + endpoint: &'a str, + account_id: &'a str, + debug: bool, + allow_script_creatives: bool, + publisher_domain: &'a str, +} + +fn aps_debug_headers(headers: &HeaderMap) -> BTreeMap> { + // This metadata is client-visible. Keep the list fail-closed so upstream + // identity or authentication headers can never leak. + const ALLOWED_HEADERS: &[HeaderName] = &[header::CONTENT_TYPE]; + + let mut values = BTreeMap::>::new(); + for (name, value) in headers { + if !ALLOWED_HEADERS.contains(name) { + continue; + } + let Ok(value) = value.to_str() else { + continue; + }; + values + .entry(name.as_str().to_string()) + .or_default() + .push(value.to_string()); + } + values +} + +fn aps_debug_body_preview(body: &[u8]) -> String { + let preview_len = body.len().min(MAX_DEBUG_RESPONSE_PREVIEW_BYTES); + let mut preview = String::from_utf8_lossy(&body[..preview_len]).into_owned(); + if body.len() > preview_len { + preview.push_str(&format!("…(truncated {} bytes)", body.len() - preview_len)); + } + preview +} + +fn attach_planned_aps_metadata( + mut response: AuctionResponse, + policy: &PlannedApsResponsePolicy<'_>, + input: &ProviderAuctionInput, + debug_request: Option, + response_body: Option<&[u8]>, + response_headers: &BTreeMap>, + status: StatusCode, +) -> AuctionResponse { + response.metadata.insert( + "routing".to_string(), + json!({ + "unused_bidder_params_count": ignored_bidder_params_count(input) + }), + ); + if !policy.debug { + return response; + } + + let mut http_call = json!({ + "responseheaders": response_headers, + "status": status.as_u16(), + "uri": policy.endpoint, + }); + if let Some(http_call) = http_call.as_object_mut() { + if let Some(request) = debug_request { + http_call.insert("requestbody".to_string(), json!(request.body)); + http_call.insert("requestheaders".to_string(), json!(request.headers)); + } + if let Some(response_body) = response_body { + http_call.insert( + "responsebody".to_string(), + json!(aps_debug_body_preview(response_body)), + ); + } + } + response.with_metadata( + "debug", + json!({ + "httpcalls": { + (APS_INTEGRATION_ID): [http_call] + } + }), + ) +} + +fn planned_aps_renderer( + policy: &PlannedApsResponsePolicy<'_>, + input: ApsRendererInput<'_>, +) -> Option { + let tag_type_value = match input.tag_type { + ApsTagType::Iframe => "iframe", + ApsTagType::Script => "script", + }; + let envelope = json!({ + "seatbid": [{ + "bid": [{ + "id": input.bid_id, + "price": input.price, + "w": input.width, + "h": input.height, + "ext": { + "creativeurl": input.creative_url, + "tagtype": tag_type_value + } + }] + }] + }); + let serialized = serde_json::to_vec(&envelope).ok()?; + if serialized.len() > MAX_RENDER_ENVELOPE_BYTES { + return None; + } + Some(BidRenderer::Aps(ApsRendererV1 { + version: 1, + account_id: policy.account_id.to_string(), + bid_id: input.bid_id.to_string(), + creative_id: input.creative_id, + tag_type: input.tag_type, + creative_url: input.creative_url.to_string(), + aax_response: BASE64_STANDARD.encode(serialized), + width: input.width, + height: input.height, + })) +} + +fn planned_aps_valid_creative_url(value: &str, publisher_domain: &str) -> bool { + if value.len() > MAX_CREATIVE_URL_BYTES { + return false; + } + let Ok(parsed) = Url::parse(value) else { + return false; + }; + parsed.scheme() == "https" + && parsed + .host_str() + .is_some_and(|host| !host.eq_ignore_ascii_case(publisher_domain)) + && parsed.username().is_empty() + && parsed.password().is_none() +} + +fn planned_aps_parse_bid( + policy: &PlannedApsResponsePolicy<'_>, + value: &Json, + slots: &HashMap<&str, HashSet<(u32, u32)>>, + returned_seat: Option<&str>, +) -> Result { + let bid_id = value + .get("id") + .and_then(Json::as_str) + .filter(|value| !value.is_empty()) + .ok_or("missing_render_source")?; + let slot_id = value + .get("impid") + .and_then(Json::as_str) + .ok_or("unknown_impid")?; + let dimensions = slots.get(slot_id).ok_or("unknown_impid")?; + let price = value + .get("price") + .and_then(Json::as_f64) + .filter(|price| price.is_finite() && *price >= 0.0) + .ok_or("invalid_price")?; + if value + .get("mtype") + .is_some_and(|mtype| mtype.as_i64() != Some(1)) + { + return Err("unsupported_media_type"); + } + let width = value + .get("w") + .and_then(Json::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or("invalid_dimensions")?; + let height = value + .get("h") + .and_then(Json::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or("invalid_dimensions")?; + if width == 0 || height == 0 || !dimensions.contains(&(width, height)) { + return Err("invalid_dimensions"); + } + let ext = value + .get("ext") + .and_then(Json::as_object) + .ok_or("missing_render_source")?; + let creative_url = ext + .get("creativeurl") + .and_then(Json::as_str) + .ok_or("missing_render_source")?; + if !planned_aps_valid_creative_url(creative_url, policy.publisher_domain) { + return Err("invalid_creative_url"); + } + let tag_type = match ext.get("tagtype").and_then(Json::as_str) { + Some("iframe") => ApsTagType::Iframe, + Some("script") if policy.allow_script_creatives => ApsTagType::Script, + Some("script") => return Err("script_rendering_disabled"), + _ => return Err("unsupported_tagtype"), + }; + let creative_id = value + .get("crid") + .and_then(Json::as_str) + .filter(|creative_id| !creative_id.is_empty()) + .map(str::to_string); + if creative_id + .as_ref() + .is_some_and(|creative_id| creative_id.len() > MAX_CREATIVE_ID_BYTES) + { + return Err("creative_id_too_large"); + } + let renderer = planned_aps_renderer( + policy, + ApsRendererInput { + bid_id, + creative_id: creative_id.clone(), + tag_type, + creative_url, + price, + width, + height, + }, + ) + .ok_or("render_payload_too_large")?; + let adomain = value + .get("adomain") + .and_then(Json::as_array) + .map(|domains| { + domains + .iter() + .filter_map(Json::as_str) + .map(str::to_string) + .collect() + }); + + Ok(Bid { + slot_id: slot_id.to_string(), + price: Some(price), + currency: DEFAULT_CURRENCY.to_string(), + creative: None, + adomain, + bidder: APS_INTEGRATION_ID.to_string(), + returned_seat: returned_seat.map(str::to_string), + width, + height, + nurl: None, + burl: None, + bid_id: Some(bid_id.to_string()), + ad_id: value.get("adid").and_then(Json::as_str).map(str::to_string), + creative_id, + renderer: Some(renderer), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + }) +} + +fn increment_planned_aps_reason(reasons: &mut BTreeMap, reason: &'static str) { + *reasons.entry(reason.to_string()).or_default() += 1; +} + +fn parse_planned_aps_value( + value: &Json, + response_time_ms: u64, + input: &ProviderAuctionInput, + policy: &PlannedApsResponsePolicy<'_>, +) -> AuctionResponse { + if !value.is_object() + || value.get("contextual").is_some() + || value + .get("cur") + .is_some_and(|currency| !currency.is_string()) + || value + .get("seatbid") + .is_some_and(|seatbids| !seatbids.is_array()) + { + return AuctionResponse::error(policy.provider_id, response_time_ms) + .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1})); + } + if value + .get("cur") + .and_then(Json::as_str) + .is_some_and(|currency| !currency.eq_ignore_ascii_case(DEFAULT_CURRENCY)) + { + return AuctionResponse::no_bid(policy.provider_id, response_time_ms) + .with_metadata("drop_reasons", json!({"unsupported_currency": 1})); + } + + let slots = input + .slots() + .iter() + .map(|slot| { + let dimensions = slot + .slot() + .formats + .iter() + .filter(|format| format.media_type == MediaType::Banner) + .map(|format| (format.width, format.height)) + .collect::>(); + (slot.slot().id.as_str(), dimensions) + }) + .collect::>(); + let seatbids = value.get("seatbid").and_then(Json::as_array); + let seatbid_count = seatbids.map_or(0, Vec::len); + let mut reasons = BTreeMap::new(); + let mut selected: HashMap = HashMap::new(); + let mut dropped = 0_u64; + + for seatbid in seatbids.into_iter().flatten() { + let returned_seat = seatbid + .get("seat") + .and_then(Json::as_str) + .filter(|seat| !seat.is_empty()); + let Some(bids) = seatbid.get("bid").and_then(Json::as_array) else { + dropped += 1; + increment_planned_aps_reason(&mut reasons, "empty_seatbid_bids"); + continue; + }; + for value in bids { + match planned_aps_parse_bid(policy, value, &slots, returned_seat) { + Ok(candidate) => { + let replace = selected.get(&candidate.slot_id).is_none_or(|current| { + let candidate_price = candidate.price.unwrap_or_default(); + let current_price = current.price.unwrap_or_default(); + candidate_price > current_price + || (candidate_price == current_price + && candidate.bid_id.as_deref().unwrap_or_default() + < current.bid_id.as_deref().unwrap_or_default()) + }); + if replace { + if selected + .insert(candidate.slot_id.clone(), candidate) + .is_some() + { + dropped += 1; + increment_planned_aps_reason(&mut reasons, "lost_to_higher_bid"); + } + } else { + dropped += 1; + increment_planned_aps_reason(&mut reasons, "lost_to_higher_bid"); + } + } + Err(reason) => { + dropped += 1; + increment_planned_aps_reason(&mut reasons, reason); + } + } + } + } + + if seatbid_count == 0 { + increment_planned_aps_reason(&mut reasons, "empty_seatbid"); + } + let accepted = selected.len(); + let metadata = [ + ("seatbid_count".to_string(), json!(seatbid_count)), + ("accepted_bid_count".to_string(), json!(accepted)), + ("dropped_bid_count".to_string(), json!(dropped)), + ("drop_reasons".to_string(), json!(reasons)), + ]; + let mut response = if selected.is_empty() { + AuctionResponse::no_bid(policy.provider_id, response_time_ms) + } else { + AuctionResponse::success( + policy.provider_id, + selected.into_values().collect(), + response_time_ms, + ) + }; + response.metadata.extend(metadata); + response +} + +/// Parse one APS-profile response using only provider-local routed state. +pub(crate) async fn parse_planned_aps_response( + provider_id: &str, + profile: &ApsProfilePlan, + endpoint: &str, + input: &ProviderAuctionInput, + response: PlatformResponse, + response_time_ms: u64, + debug_request: Option, +) -> Result> { + let policy = PlannedApsResponsePolicy { + provider_id, + endpoint, + account_id: &profile.account_id, + debug: profile.debug, + allow_script_creatives: profile.allow_script_creatives, + publisher_domain: &input.common_request().publisher.domain, + }; + let response = response.response; + let status = response.status(); + let response_headers = if policy.debug { + aps_debug_headers(response.headers()) + } else { + BTreeMap::new() + }; + + if status == StatusCode::NO_CONTENT { + return Ok(attach_planned_aps_metadata( + AuctionResponse::no_bid(provider_id, response_time_ms), + &policy, + input, + debug_request, + Some(&[]), + &response_headers, + status, + )); + } + if !status.is_success() { + log::warn!("APS profile {provider_id} returns a non-success status"); + let body = if policy.debug { + match collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + APS_INTEGRATION_ID, + ) + .await + { + Ok(body) => Some(body), + Err(error) => { + log::warn!("Failed to read APS profile debug response body: {error:?}"); + None + } + } + } else { + None + }; + return Ok(attach_planned_aps_metadata( + AuctionResponse::error(provider_id, response_time_ms), + &policy, + input, + debug_request, + body.as_deref(), + &response_headers, + status, + )); + } + let body = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + APS_INTEGRATION_ID, + ) + .await + .change_context(TrustedServerError::Auction { + message: format!("Failed to read APS profile {provider_id} response body"), + })?; + let value: Json = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(error) => { + log::warn!("Failed to parse APS profile {provider_id} response JSON: {error}"); + let parsed = AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1})); + return Ok(attach_planned_aps_metadata( + parsed, + &policy, + input, + debug_request, + Some(&body), + &response_headers, + status, + )); + } + }; + let parsed = parse_planned_aps_value(&value, response_time_ms, input, &policy); + Ok(attach_planned_aps_metadata( + parsed, + &policy, + input, + debug_request, + Some(&body), + &response_headers, + status, + )) +} + +/// Legacy APS `OpenRTB` auction provider retained only for parity tests. +#[cfg(test)] pub struct ApsAuctionProvider { - config: ApsConfig, + config: LegacyApsProviderConfig, } +#[cfg(test)] impl ApsAuctionProvider { /// Create an APS provider from validated configuration. #[must_use] - pub fn new(config: ApsConfig) -> Self { + pub fn new(config: LegacyApsProviderConfig) -> Self { Self { config } } @@ -816,6 +1407,7 @@ impl ApsAuctionProvider { creative: None, adomain, bidder: APS_INTEGRATION_ID.to_string(), + returned_seat: None, width, height, nurl: None, @@ -1042,9 +1634,10 @@ impl ApsAuctionProvider { } } +#[cfg(test)] #[async_trait(?Send)] impl AuctionProvider for ApsAuctionProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { APS_INTEGRATION_ID } @@ -1230,19 +1823,18 @@ impl IntegrationProxy for ApsRendererIntegration { /// # Errors /// /// Returns an error when enabled APS configuration is invalid. -pub fn register( - settings: &Settings, -) -> Result, Report> { - let Some(_config) = settings.integration_config::(APS_INTEGRATION_ID)? else { - return Ok(None); - }; +#[must_use] +pub fn register_for_plan(plan: &crate::auction::AuctionPlan) -> Option { + if !plan.has_profile(APS_INTEGRATION_ID) { + return None; + } let integration = Arc::new(ApsRendererIntegration); - Ok(Some( + Some( IntegrationRegistration::builder(APS_INTEGRATION_ID) .with_proxy(integration) .without_js() .build(), - )) + ) } /// Register the APS auction provider when enabled. @@ -1250,10 +1842,45 @@ pub fn register( /// # Errors /// /// Returns an error when enabled APS configuration is invalid. +#[cfg(test)] +#[allow(clippy::missing_panics_doc)] +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(_config) = + settings.integration_config::(APS_INTEGRATION_ID)? + else { + return Ok(None); + }; + Ok(register_for_plan( + &crate::auction::AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 1000, + providers: BTreeMap::from([( + "aps".parse().expect("should parse APS provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: default_endpoint(), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({"account_id":"example-account"}), + }, + )]), + ..AuctionPlanConfig::default() + }) + .expect("should compile APS renderer test plan"), + )) +} + +#[cfg(test)] +#[allow(clippy::missing_errors_doc)] pub fn register_providers( settings: &Settings, ) -> Result>, Report> { - let Some(config) = settings.integration_config::(APS_INTEGRATION_ID)? else { + let Some(config) = + settings.integration_config::(APS_INTEGRATION_ID)? + else { return Ok(Vec::new()); }; log::info!("Registering APS OpenRTB provider"); @@ -1268,21 +1895,18 @@ pub fn register_providers( #[cfg(test)] mod tests { use super::*; + use crate::auction::test_support::canonical_parity_auction_request; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo, - UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, PublisherInfo, UserInfo, }; - use crate::consent::ConsentContext; - use crate::openrtb::{Eid, Uid}; - use crate::platform::GeoInfo; use crate::platform::test_support::{ StubHttpClient, build_services_with_http_client, noop_services, }; use crate::test_support::tests::create_test_settings; use serde_json::json; - fn config() -> ApsConfig { - ApsConfig { + fn config() -> LegacyApsProviderConfig { + LegacyApsProviderConfig { enabled: true, account_id: "example-account-id".to_string(), endpoint: default_endpoint(), @@ -1356,6 +1980,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; @@ -1386,15 +2011,28 @@ mod tests { .expect("should parse APS response with context") } + #[test] + fn config_defaults_to_the_800ms_aps_budget() { + let parsed: LegacyApsProviderConfig = serde_json::from_value(json!({ + "account_id": "example-account" + })) + .expect("should parse APS defaults"); + + assert_eq!( + parsed.timeout_ms, 800, + "should preserve APS's 800ms default" + ); + } + #[test] fn config_accepts_canonical_alias_and_integer_ids() { - let canonical: ApsConfig = serde_json::from_value(json!({ + let canonical: LegacyApsProviderConfig = serde_json::from_value(json!({ "account_id": " example-account " })) .expect("should parse canonical account ID"); - let alias: ApsConfig = + let alias: LegacyApsProviderConfig = serde_json::from_value(json!({"pub_id": 1234})).expect("should parse legacy alias"); - let debug: ApsConfig = serde_json::from_value(json!({ + let debug: LegacyApsProviderConfig = serde_json::from_value(json!({ "account_id": "example-account", "debug": true })) @@ -1410,11 +2048,11 @@ mod tests { #[test] fn config_accepts_default_and_custom_openrtb_endpoints() { - let default = ApsConfig { + let default = LegacyApsProviderConfig { account_id: "example-account".to_string(), ..Default::default() }; - let custom: ApsConfig = serde_json::from_value(json!({ + let custom: LegacyApsProviderConfig = serde_json::from_value(json!({ "account_id": "example-account", "endpoint": "https://aps.example.com/custom/openrtb" })) @@ -1435,7 +2073,7 @@ mod tests { "https://aps.example.com/e/dtb/bid/", "https://aps.example.com/custom/e/dtb/bid", ] { - let parsed: ApsConfig = serde_json::from_value(json!({ + let parsed: LegacyApsProviderConfig = serde_json::from_value(json!({ "account_id": "example-account", "endpoint": endpoint })) @@ -1452,15 +2090,18 @@ mod tests { #[test] fn config_rejects_blank_duplicate_and_unsafe_endpoint() { - assert!(serde_json::from_value::(json!({"account_id": " "})).is_err()); assert!( - serde_json::from_value::( + serde_json::from_value::(json!({"account_id": " "})) + .is_err() + ); + assert!( + serde_json::from_value::( json!({"account_id": "x".repeat(MAX_ACCOUNT_ID_BYTES + 1)}) ) .is_err() ); assert!( - serde_json::from_value::(json!({ + serde_json::from_value::(json!({ "account_id": "one", "pub_id": "two" })) @@ -1471,7 +2112,7 @@ mod tests { "https://", "https://user:password@aps.example/e/pb/bid", ] { - let parsed: ApsConfig = serde_json::from_value(json!({ + let parsed: LegacyApsProviderConfig = serde_json::from_value(json!({ "account_id": "example-account", "endpoint": endpoint })) @@ -1512,7 +2153,7 @@ mod tests { "inventory_page_origin": "https://unrelated.example" }), ] { - let parsed: ApsConfig = + let parsed: LegacyApsProviderConfig = serde_json::from_value(value).expect("should deserialize before validation"); assert!( parsed.validate().is_err(), @@ -1523,7 +2164,7 @@ mod tests { #[test] fn inventory_identity_override_rewrites_site_and_preserves_page_path() { - let config: ApsConfig = serde_json::from_value(json!({ + let config: LegacyApsProviderConfig = serde_json::from_value(json!({ "enabled": true, "account_id": "example-account", "inventory_domain": "publisher.example", @@ -1552,6 +2193,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; @@ -1581,54 +2223,7 @@ mod tests { #[test] fn builds_aps_openrtb_request_with_explicit_privacy_policy() { let provider = ApsAuctionProvider::new(config()); - let mut auction_request = request(); - auction_request.user.consent = Some(ConsentContext { - gdpr_applies: true, - raw_tc_string: Some("fictional-tcf".to_string()), - raw_us_privacy: Some("1YNN".to_string()), - raw_gpp_string: Some("fictional-gpp".to_string()), - gpp_section_ids: Some(vec![2, 6]), - ..Default::default() - }); - auction_request.user.eids = Some(vec![Eid { - source: "identity.example".to_string(), - uids: vec![Uid { - id: "fictional-uid".to_string(), - atype: Some(1), - ext: None, - }], - }]); - auction_request.slots[0].formats.extend([ - AdFormat { - media_type: MediaType::Video, - width: 640, - height: 480, - }, - AdFormat { - media_type: MediaType::Banner, - width: u32::MAX, - height: 90, - }, - AdFormat { - media_type: MediaType::Banner, - width: 728, - height: 90, - }, - ]); - auction_request.device = Some(DeviceInfo { - user_agent: Some("Fictional Browser".to_string()), - ip: Some("192.0.2.10".to_string()), - geo: Some(GeoInfo { - city: "Example City".to_string(), - country: "US".to_string(), - continent: "NA".to_string(), - latitude: 12.34, - longitude: 56.78, - metro_code: 501, - region: Some("CA".to_string()), - asn: None, - }), - }); + let auction_request = canonical_parity_auction_request(); let settings = create_test_settings(); let services = noop_services(); let downstream = http::Request::builder() @@ -1642,12 +2237,13 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; let openrtb = provider.build_openrtb_request(&auction_request, &context); - let serialized = serde_json::to_value(openrtb).expect("should serialize request"); + let serialized = serde_json::to_value(&openrtb).expect("should serialize request"); assert_eq!(serialized["id"], "fictional-auction"); assert_eq!(serialized["tmax"], 321); @@ -1706,6 +2302,19 @@ mod tests { assert!(serialized["ext"].get("prebid").is_none()); assert!(serialized["ext"].get("trusted_server").is_none()); assert!(serialized["imp"][0].get("ext").is_none()); + assert!( + serialized["user"]["ext"].get("ConsentSettings").is_none(), + "should omit PBS-only Google Additional Consent placement" + ); + assert!( + serialized["imp"][0].get("tagid").is_none(), + "should ignore shared trustedServer bidder parameters" + ); + assert_eq!( + serde_json::to_string(&openrtb).expect("should serialize APS request"), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}],"w":300,"h":250,"topframe":0},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"account":"example-account-id","sdk":{"source":"prebid","version":"2.2.0"}}}"#, + "should preserve the complete APS wire shape without a signing extension" + ); } #[test] @@ -1726,6 +2335,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; @@ -1742,13 +2352,17 @@ mod tests { fn parses_bid_and_builds_exact_minimized_envelope() { let provider = ApsAuctionProvider::new(config()); let response = provider.parse_aps_response( - &json!({"cur": "USD", "seatbid": [{"seat": 42, "bid": [bid("fictional-selected-bid-id", 1.23, "iframe")]}], "ext": {"userSyncs": []}}), + &json!({"cur": "USD", "seatbid": [{"seat": "fictional-upstream-seat", "bid": [bid("fictional-selected-bid-id", 1.23, "iframe")]}], "ext": {"userSyncs": []}}), 12, &request(), ); assert_eq!(response.bids.len(), 1); let parsed = &response.bids[0]; assert_eq!(parsed.bidder, "aps"); + assert!( + parsed.returned_seat.is_none(), + "legacy APS parsing must not attach planned telemetry identity" + ); assert_eq!(parsed.price, Some(1.23)); assert!(parsed.creative.is_none()); assert!(parsed.nurl.is_none()); @@ -1903,6 +2517,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index f8472b796..4b0989183 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -412,7 +412,14 @@ mod tests { .insert_config(DIDOMI_INTEGRATION_ID, &config(true)) .expect("should insert config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); assert!(registry.has_route(&Method::GET, "/integrations/didomi/consent/loader.js")); assert!(registry.has_route(&Method::POST, "/integrations/didomi/consent/api/events")); assert!(!registry.has_route(&Method::GET, "/other")); @@ -505,7 +512,14 @@ mod tests { .insert_config(DIDOMI_INTEGRATION_ID, &custom_config) .expect("should insert config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); assert!(registry.has_route(&Method::GET, "/my-custom-consent/loader.js")); assert!(registry.has_route(&Method::POST, "/my-custom-consent/api/events")); assert!(!registry.has_route(&Method::GET, "/integrations/didomi/consent/loader.js")); diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 0dfb5906f..21224516e 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1609,7 +1609,14 @@ container_id = "GTM-DEFAULT" ) .expect("should update gtm config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -1649,7 +1656,14 @@ container_id = "GTM-DEFAULT" .expect("should update gtm config"); // 2. Setup Pipeline - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -1715,7 +1729,14 @@ container_id = "GTM-DEFAULT" .expect("should update config"); // Inlined Pipeline Creation - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -2041,7 +2062,14 @@ container_id = "GTM-DEFAULT" ) .expect("should update config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); @@ -2107,7 +2135,14 @@ container_id = "GTM-DEFAULT" ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); @@ -2173,7 +2208,14 @@ container_id = "GTM-DEFAULT" ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 5bd86d19e..bd22e256e 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -387,7 +387,12 @@ mod tests { #[test] fn register_excludes_diagnostics_from_unified_and_deferred_bundles() { - let registry = IntegrationRegistry::new(&settings(true)).expect("should build registry"); + let settings = settings(true); + let plan = std::sync::Arc::new( + crate::auction::compile_auction_plan(&settings).expect("should compile auction plan"), + ); + let registry = + IntegrationRegistry::with_plan(&settings, plan).expect("should build registry"); assert!(registry.integration_enabled(GPT_DIAGNOSTICS_INTEGRATION_ID)); assert!( diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 90d688693..742960970 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -289,14 +289,6 @@ pub(crate) struct IntegrationBuilder { pub(crate) fn builders() -> &'static [IntegrationBuilder] { &[ - IntegrationBuilder { - id: "aps", - build: aps::register, - }, - IntegrationBuilder { - id: "prebid", - build: prebid::register, - }, IntegrationBuilder { id: "testlight", build: testlight::register, diff --git a/crates/trusted-server-core/src/integrations/nextjs/mod.rs b/crates/trusted-server-core/src/integrations/nextjs/mod.rs index 5452260e7..015ba2475 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/mod.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/mod.rs @@ -160,7 +160,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -246,7 +253,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -316,7 +330,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -362,7 +383,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -411,7 +439,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -474,7 +509,14 @@ mod tests { ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -543,7 +585,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); // Use small chunk size to force fragmentation @@ -604,7 +653,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); @@ -666,7 +722,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 4cc10f8da..79d61dcb8 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,5 +1,8 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +#[cfg(test)] +use std::collections::HashSet; use std::sync::Arc; +#[cfg(test)] use std::time::Duration; use async_trait::async_trait; @@ -19,27 +22,39 @@ use url::{Url, Url as ParsedUrl}; use validator::{Validate, ValidationError}; use crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; +use crate::auction::plan::AuctionPlan; +use crate::auction::profile::PrebidProfilePlan; +#[cfg(test)] use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; -use crate::auction::types::{ - AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, -}; +use crate::auction::routing::{PrebidTransportHeaders, ProviderAuctionInput}; +#[cfg(test)] +use crate::auction::types::{AuctionContext, AuctionRequest, MediaType}; +use crate::auction::types::{AuctionResponse, Bid as AuctionBid}; use crate::consent_config::ConsentForwardingMode; use crate::cookies::{CONSENT_COOKIE_NAMES, strip_cookies}; use crate::error::TrustedServerError; +#[cfg(test)] use crate::http_util::RequestInfo; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded, +}; +#[cfg(test)] +use crate::integrations::{ ensure_integration_backend_with_timeout, predict_integration_backend_name, }; +#[cfg(test)] use crate::openrtb::{ Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, ImpStoredRequest, OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, to_openrtb_i32, }; -use crate::platform::{PlatformHttpRequest, PlatformResponse, RuntimeServices}; +#[cfg(test)] +use crate::platform::PlatformHttpRequest; +use crate::platform::{PlatformResponse, RuntimeServices}; use crate::proxy::{ProxyRequestConfig, is_host_allowed, proxy_request}; +#[cfg(test)] use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; use crate::settings::{IntegrationConfig, Settings}; @@ -53,8 +68,11 @@ const PREBID_BUNDLE_ERROR_CACHE_CONTROL: &str = "no-store"; const PREBID_BUNDLE_ERROR_CONTENT_TYPE: &str = "text/plain; charset=utf-8"; const PREBID_BUNDLE_NOSNIFF_HEADER: &str = "x-content-type-options"; const PREBID_BUNDLE_NOSNIFF_VALUE: &str = "nosniff"; +#[cfg(test)] const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; +#[cfg(test)] const BIDDER_PARAMS_KEY: &str = "bidderParams"; +#[cfg(test)] const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. @@ -198,8 +216,9 @@ fn extract_prebid_error_message( #[cfg(test)] const GPC_US_PRIVACY: &str = "1YYN"; +#[cfg(test)] #[derive(Debug, Clone, Deserialize, Serialize, Validate)] -pub struct PrebidIntegrationConfig { +pub struct LegacyPrebidServerConfig { #[serde(default = "default_enabled")] pub enabled: bool, #[validate(url)] @@ -337,13 +356,106 @@ pub struct PrebidIntegrationConfig { pub suppress_nurl_bidders: Vec, } +#[cfg(test)] +impl IntegrationConfig for LegacyPrebidServerConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// CLI build inputs retained in app config but ignored safely by the runtime. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PrebidBundleBuildConfig { + /// Prebid.js bidder adapters included by `ts prebid bundle`. + #[serde(default)] + pub adapters: Vec, + /// Optional Prebid.js user ID modules included by `ts prebid bundle`. + #[serde(default)] + pub user_id_modules: Option>, +} + +/// Browser-only Prebid integration settings. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PrebidIntegrationConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub account_id: Option, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u32, + #[serde(default)] + pub debug: bool, + #[serde( + default = "default_script_patterns", + deserialize_with = "crate::settings::vec_from_seq_or_map" + )] + pub script_patterns: Vec, + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_url"))] + pub external_bundle_url: Option, + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sha256"))] + pub external_bundle_sha256: Option, + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sri"))] + pub external_bundle_sri: Option, + #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] + pub client_side_bidders: Vec, + #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] + #[validate(custom(function = "validate_excluded_gam_ad_unit_path_suffixes"))] + pub excluded_gam_ad_unit_path_suffixes: Vec, + /// CLI-only external bundle build inputs; runtime registration ignores these fields. + #[serde(default)] + pub bundle: PrebidBundleBuildConfig, +} + +impl Default for PrebidIntegrationConfig { + fn default() -> Self { + Self { + enabled: default_enabled(), + account_id: None, + timeout_ms: default_timeout_ms(), + debug: false, + script_patterns: default_script_patterns(), + external_bundle_url: None, + external_bundle_sha256: None, + external_bundle_sri: None, + client_side_bidders: Vec::new(), + excluded_gam_ad_unit_path_suffixes: Vec::new(), + bundle: PrebidBundleBuildConfig::default(), + } + } +} + impl IntegrationConfig for PrebidIntegrationConfig { fn is_enabled(&self) -> bool { self.enabled } } -fn remove_aps_bidders(config: &mut PrebidIntegrationConfig) { +#[cfg(test)] +impl From<&LegacyPrebidServerConfig> for PrebidIntegrationConfig { + fn from(config: &LegacyPrebidServerConfig) -> Self { + Self { + enabled: config.enabled, + account_id: config.account_id.clone(), + timeout_ms: config.timeout_ms, + debug: config.debug, + script_patterns: config.script_patterns.clone(), + external_bundle_url: config.external_bundle_url.clone(), + external_bundle_sha256: config.external_bundle_sha256.clone(), + external_bundle_sri: config.external_bundle_sri.clone(), + client_side_bidders: config.client_side_bidders.clone(), + excluded_gam_ad_unit_path_suffixes: config.excluded_gam_ad_unit_path_suffixes.clone(), + bundle: PrebidBundleBuildConfig::default(), + } + } +} + +#[cfg(test)] +fn remove_aps_bidders(config: &mut LegacyPrebidServerConfig) { for (field, bidders) in [ ("bidders", &mut config.bidders), ("client_side_bidders", &mut config.client_side_bidders), @@ -400,7 +512,8 @@ fn validate_excluded_gam_ad_unit_path_suffixes(values: &[String]) -> Result<(), Ok(()) } -fn canonicalize_excluded_gam_ad_unit_path_suffixes(config: &mut PrebidIntegrationConfig) { +#[cfg(test)] +fn canonicalize_excluded_gam_ad_unit_path_suffixes(config: &mut LegacyPrebidServerConfig) { let mut canonical = Vec::with_capacity(config.excluded_gam_ad_unit_path_suffixes.len()); for suffix in std::mem::take(&mut config.excluded_gam_ad_unit_path_suffixes) { if !canonical.contains(&suffix) { @@ -410,11 +523,12 @@ fn canonicalize_excluded_gam_ad_unit_path_suffixes(config: &mut PrebidIntegratio config.excluded_gam_ad_unit_path_suffixes = canonical; } +#[cfg(test)] fn load_config( settings: &Settings, -) -> Result, Report> { +) -> Result, Report> { let Some(mut config) = - settings.integration_config::(PREBID_INTEGRATION_ID)? + settings.integration_config::(PREBID_INTEGRATION_ID)? else { return Ok(None); }; @@ -429,9 +543,10 @@ fn load_config( /// /// Returns a configuration error if enabled Prebid settings fail typed parsing, /// schema validation, or bidder-param override compilation. +#[cfg(test)] pub fn validate_config_for_startup( settings: &Settings, -) -> Result, Report> { +) -> Result, Report> { let Some(config) = load_config(settings)? else { return Ok(None); }; @@ -473,6 +588,7 @@ fn default_timeout_ms() -> u32 { 1000 } +#[cfg(test)] fn default_bidders() -> Vec { vec!["mocktioneer".to_string()] } @@ -608,11 +724,11 @@ fn validate_external_bundle_sri(value: &str) -> Result<(), ValidationError> { parse_external_bundle_sri(value) } -fn validate_external_bundle_config( - config: &PrebidIntegrationConfig, +fn validate_external_bundle_url_allowed( + external_bundle_url: Option<&str>, allowed_domains: &[String], ) -> Result<(), Report> { - let url = config.external_bundle_url.as_deref().ok_or_else(|| { + let url = external_bundle_url.ok_or_else(|| { Report::new(TrustedServerError::Configuration { message: "integrations.prebid.external_bundle_url is required when prebid is enabled" .to_string(), @@ -660,25 +776,67 @@ fn validate_external_bundle_config( Ok(()) } +pub(crate) fn validate_browser_config_for_startup( + config: &PrebidIntegrationConfig, + allowed_domains: &[String], +) -> Result<(), Report> { + validate_external_bundle_url_allowed(config.external_bundle_url.as_deref(), allowed_domains) +} + +#[cfg(test)] +fn validate_external_bundle_config( + config: &LegacyPrebidServerConfig, + allowed_domains: &[String], +) -> Result<(), Report> { + validate_external_bundle_url_allowed(config.external_bundle_url.as_deref(), allowed_domains) +} + pub struct PrebidIntegration { config: PrebidIntegrationConfig, + planned_head_inserts: Option>, + #[cfg(test)] + legacy_config: Option, + #[cfg(test)] engine: Arc, } impl PrebidIntegration { - fn try_new(config: PrebidIntegrationConfig) -> Result, Report> { + #[cfg(test)] + fn try_new(config: LegacyPrebidServerConfig) -> Result, Report> { let engine = Arc::new(BidParamOverrideEngine::try_from_config(&config)?); - Ok(Arc::new(Self { config, engine })) + Ok(Arc::new(Self { + config: PrebidIntegrationConfig::from(&config), + planned_head_inserts: None, + legacy_config: Some(config), + engine, + })) } #[cfg(test)] - fn new(config: PrebidIntegrationConfig) -> Arc { + fn new(config: LegacyPrebidServerConfig) -> Arc { Self::try_new(config).expect("should compile prebid bid param overrides") } + fn for_browser_plan(config: &PrebidIntegrationConfig, plan: &AuctionPlan) -> Arc { + let mut integration = Self { + config: config.clone(), + planned_head_inserts: None, + #[cfg(test)] + legacy_config: None, + #[cfg(test)] + engine: Arc::new(BidParamOverrideEngine::default()), + }; + integration.planned_head_inserts = Some(integration.head_inserts_for_plan(config, plan)); + Arc::new(integration) + } + + #[cfg(test)] fn auction_provider(&self) -> PrebidAuctionProvider { PrebidAuctionProvider { - config: self.config.clone(), + config: self + .legacy_config + .clone() + .expect("should retain legacy config for legacy provider tests"), bid_param_override_engine: Arc::clone(&self.engine), } } @@ -764,23 +922,53 @@ impl PrebidIntegration { }) } - fn external_bundle_script_src(&self) -> String { - match self.config.external_bundle_sha256.as_deref() { - Some(sha256) => format!("{PREBID_BUNDLE_ROUTE}?v={sha256}"), - None => PREBID_BUNDLE_ROUTE.to_string(), - } + fn external_bundle_script_tag(&self) -> String { + external_bundle_script_tag( + self.config.external_bundle_sha256.as_deref(), + self.config.external_bundle_sri.as_deref(), + ) } - fn external_bundle_script_tag(&self) -> String { - let src = self.external_bundle_script_src(); - let integrity = self - .config - .external_bundle_sri - .as_deref() - .map(|value| format!(" integrity=\"{}\"", escape_html_attr(value))) - .unwrap_or_default(); + /// Build the prepared browser injection from browser settings and validated routes. + pub(crate) fn head_inserts_for_plan( + &self, + browser_config: &PrebidIntegrationConfig, + plan: &AuctionPlan, + ) -> Vec { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct InjectedBrowserConfig<'a> { + account_id: &'a str, + timeout: u32, + debug: bool, + server_side_bidders: Vec<&'a str>, + #[serde(skip_serializing_if = "<[String]>::is_empty")] + client_side_bidders: &'a [String], + #[serde(skip_serializing_if = "<[String]>::is_empty")] + excluded_gam_ad_unit_path_suffixes: &'a [String], + } + + let payload = InjectedBrowserConfig { + account_id: browser_config.account_id.as_deref().unwrap_or_default(), + timeout: browser_config.timeout_ms, + debug: browser_config.debug, + server_side_bidders: if plan.enabled() { + plan.browser_bidder_codes().collect() + } else { + Vec::new() + }, + client_side_bidders: &browser_config.client_side_bidders, + excluded_gam_ad_unit_path_suffixes: &browser_config.excluded_gam_ad_unit_path_suffixes, + }; + let config_json = serialize_injected_prebid_config(&payload); - format!("") + vec![ + injected_prebid_config_script(&config_json), + external_bundle_script_tag( + browser_config.external_bundle_sha256.as_deref(), + browser_config.external_bundle_sri.as_deref(), + ), + ] } fn is_managed_external(&self) -> bool { @@ -949,6 +1137,23 @@ fn escape_html_attr(value: &str) -> String { .replace('>', ">") } +fn external_bundle_script_src(sha256: Option<&str>) -> String { + match sha256 { + Some(sha256) => format!("{PREBID_BUNDLE_ROUTE}?v={sha256}"), + None => PREBID_BUNDLE_ROUTE.to_string(), + } +} + +fn external_bundle_script_tag(sha256: Option<&str>, sri: Option<&str>) -> String { + let src = external_bundle_script_src(sha256); + let integrity = sri + .map(|value| format!(" integrity=\"{}\"", escape_html_attr(value))) + .unwrap_or_default(); + + format!("") +} + +#[cfg(test)] fn build( settings: &Settings, ) -> Result>, Report> { @@ -980,6 +1185,36 @@ fn build( /// /// Returns an error when the Prebid integration is enabled with invalid /// configuration. +pub fn register_for_plan( + settings: &Settings, + plan: &AuctionPlan, +) -> Result, Report> { + let Some(mut config) = + settings.integration_config::(PREBID_INTEGRATION_ID)? + else { + return Ok(None); + }; + let mut canonical = Vec::with_capacity(config.excluded_gam_ad_unit_path_suffixes.len()); + for suffix in std::mem::take(&mut config.excluded_gam_ad_unit_path_suffixes) { + if !canonical.contains(&suffix) { + canonical.push(suffix); + } + } + config.excluded_gam_ad_unit_path_suffixes = canonical; + validate_browser_config_for_startup(&config, &settings.proxy.allowed_domains)?; + let integration = PrebidIntegration::for_browser_plan(&config, plan); + Ok(Some( + IntegrationRegistration::builder(PREBID_INTEGRATION_ID) + .with_proxy(integration.clone()) + .with_attribute_rewriter(integration.clone()) + .with_head_injector(integration) + .with_deferred_js() + .build(), + )) +} + +#[cfg(test)] +#[allow(clippy::missing_errors_doc)] pub fn register( settings: &Settings, ) -> Result, Report> { @@ -1069,12 +1304,31 @@ impl IntegrationAttributeRewriter for PrebidIntegration { } } +fn serialize_injected_prebid_config(payload: &impl Serialize) -> String { + // Escape ` String { + format!( + r#""# + ) +} + impl IntegrationHeadInjector for PrebidIntegration { fn integration_id(&self) -> &'static str { PREBID_INTEGRATION_ID } fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + if let Some(inserts) = &self.planned_head_inserts { + return inserts.clone(); + } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct InjectedPrebidClientConfig<'a> { @@ -1092,22 +1346,24 @@ impl IntegrationHeadInjector for PrebidIntegration { account_id: self.config.account_id.as_deref().unwrap_or_default(), timeout: self.config.timeout_ms, debug: self.config.debug, - bidders: &self.config.bidders, + bidders: { + #[cfg(test)] + { + self.legacy_config + .as_ref() + .map_or(&[][..], |config| config.bidders.as_slice()) + } + #[cfg(not(test))] + { + &[] + } + }, client_side_bidders: &self.config.client_side_bidders, excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes, }; - // Escape `window.pbjs=window.pbjs||{{}};window.pbjs.que=window.pbjs.que||[];window.pbjs.cmd=window.pbjs.cmd||[];window.__tsjs_prebid={config_json};"# - )]; + let config_json = serialize_injected_prebid_config(&payload); + let mut inserts = vec![injected_prebid_config_script(&config_json)]; inserts.push(self.external_bundle_script_tag()); @@ -1122,11 +1378,13 @@ impl IntegrationHeadInjector for PrebidIntegration { /// tell a fabricated empty from an explicitly supplied one — they are identical /// bytes on the wire. The merge uses this to stop an unusable value from /// clobbering real params, and the final pass uses it to drop whatever remains. +#[cfg(test)] fn is_unusable_bidder_params(params: &Json) -> bool { // `None` covers non-object values (e.g. `null`); an empty map covers `{}`. params.as_object().is_none_or(serde_json::Map::is_empty) } +#[cfg(test)] fn expand_trusted_server_bidders( configured_bidders: &[String], params: &Json, @@ -1184,7 +1442,8 @@ fn merge_bidder_param_object( // Generic bid-parameter override engine // ============================================================================ -fn warn_unconfigured_bidder(config: &PrebidIntegrationConfig, bidder: &str, field: &str) { +#[cfg(test)] +fn warn_unconfigured_bidder(config: &LegacyPrebidServerConfig, bidder: &str, field: &str) { if !config.bidders.iter().any(|b| b == bidder) { if config.client_side_bidders.iter().any(|b| b == bidder) { log::warn!( @@ -1201,7 +1460,7 @@ fn warn_unconfigured_bidder(config: &PrebidIntegrationConfig, bidder: &str, fiel } #[derive(Debug, Default, Clone)] -struct BidParamOverrideEngine { +pub(crate) struct BidParamOverrideEngine { rules: Vec, // Maps bidder name to the indices (into `rules`) of rules that constrain on that bidder. // Rules with no bidder constraint (zone-only or catch-all) are kept in `wildcard_indices`. @@ -1224,8 +1483,9 @@ struct BidParamOverrideFacts<'a> { } impl BidParamOverrideEngine { + #[cfg(test)] fn try_from_config( - config: &PrebidIntegrationConfig, + config: &LegacyPrebidServerConfig, ) -> Result> { let mut rules = Vec::new(); @@ -1281,6 +1541,45 @@ impl BidParamOverrideEngine { }) } + fn try_from_profile_config( + bid_param_zone_overrides: &std::collections::BTreeMap< + String, + std::collections::BTreeMap>, + >, + bid_param_overrides: &std::collections::BTreeMap>, + bid_param_override_rules: &[BidParamOverrideRule], + ) -> Result> { + let mut rules = Vec::new(); + for (bidder, set) in bid_param_overrides { + rules.push(CompiledBidParamOverrideRule::from_bidder_override( + bidder, set, + )?); + } + for (bidder, zone_override_sets) in bid_param_zone_overrides { + for (zone, set) in zone_override_sets { + rules.push(CompiledBidParamOverrideRule::from_zone_override( + bidder, zone, set, + )?); + } + } + for rule in bid_param_override_rules { + rules.push(CompiledBidParamOverrideRule::try_from(rule)?); + } + let mut bidder_index: HashMap> = HashMap::new(); + let mut wildcard_indices = Vec::new(); + for (index, rule) in rules.iter().enumerate() { + match &rule.bidder { + Some(bidder) => bidder_index.entry(bidder.clone()).or_default().push(index), + None => wildcard_indices.push(index), + } + } + Ok(Self { + rules, + bidder_index, + wildcard_indices, + }) + } + fn apply(&self, facts: BidParamOverrideFacts<'_>, params: &mut Json) { let bidder_indices = self.bidder_index.get(facts.bidder).map(Vec::as_slice); for idx in merged_rule_indices(&self.wildcard_indices, bidder_indices) { @@ -1303,6 +1602,30 @@ impl BidParamOverrideEngine { } } } + + /// Apply compiled profile rules to already centrally routed bidder params. + pub(crate) fn apply_routed(&self, bidder: &str, zone: Option<&str>, params: &mut Json) { + self.apply(BidParamOverrideFacts { bidder, zone }, params); + } +} + +/// Validate and compile server-side Prebid profile override fields. +/// +/// This narrow hook shares the existing override compiler without coupling +/// auction-profile availability to the browser integration's enablement. +pub(crate) fn compile_profile_override_rules( + bid_param_zone_overrides: &std::collections::BTreeMap< + String, + std::collections::BTreeMap>, + >, + bid_param_overrides: &std::collections::BTreeMap>, + bid_param_override_rules: &[BidParamOverrideRule], +) -> Result> { + BidParamOverrideEngine::try_from_profile_config( + bid_param_zone_overrides, + bid_param_overrides, + bid_param_override_rules, + ) } fn merged_rule_indices<'a>( @@ -1464,17 +1787,62 @@ fn non_empty_override_object( /// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are /// stripped from the `Cookie` header since consent travels exclusively /// through the `OpenRTB` body. +#[cfg(test)] fn copy_request_headers( from: &http::Request, to: &mut http::Request, consent_forwarding: ConsentForwardingMode, client_ip: Option, ) { - let headers_to_copy = [header::USER_AGENT, header::REFERER, header::ACCEPT_LANGUAGE]; + apply_prebid_header_values( + from.headers().get(header::COOKIE), + from.headers().get(header::USER_AGENT), + from.headers().get(header::REFERER), + from.headers().get(header::ACCEPT_LANGUAGE), + to, + consent_forwarding, + client_ip, + ); +} + +/// Apply the common raw-header transport policy for a planned PBS request. +pub(crate) fn apply_prebid_transport_headers( + from: &PrebidTransportHeaders, + to: &mut http::Request, + consent_forwarding: ConsentForwardingMode, + client_ip: Option, +) { + apply_prebid_header_values( + from.cookie(), + from.user_agent(), + from.referer(), + from.accept_language(), + to, + consent_forwarding, + client_ip, + ); +} - for header_name in &headers_to_copy { - if let Some(value) = from.headers().get(header_name) { - to.headers_mut().insert(header_name, value.clone()); +#[allow( + clippy::too_many_arguments, + reason = "the helper preserves four independently optional raw headers plus transport policy" +)] +fn apply_prebid_header_values( + cookie: Option<&HeaderValue>, + user_agent: Option<&HeaderValue>, + referer: Option<&HeaderValue>, + accept_language: Option<&HeaderValue>, + to: &mut http::Request, + consent_forwarding: ConsentForwardingMode, + client_ip: Option, +) { + for (name, value) in [ + (header::USER_AGENT, user_agent), + (header::REFERER, referer), + (header::ACCEPT_LANGUAGE, accept_language), + ] { + if let Some(value) = value { + to.headers_mut().insert(name, value.clone()); } } @@ -1485,24 +1853,20 @@ fn copy_request_headers( .insert(header::HeaderName::from_static("x-forwarded-for"), value); } - let Some(cookie_value) = from.headers().get(header::COOKIE) else { + let Some(cookie_value) = cookie else { return; }; - if !consent_forwarding.strips_consent_cookies() { to.headers_mut() .insert(header::COOKIE, cookie_value.clone()); return; } - match cookie_value.to_str() { Ok(value) => { let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); - if stripped.is_empty() { - return; - } - - if let Ok(cookie_header) = HeaderValue::from_str(&stripped) { + if !stripped.is_empty() + && let Ok(cookie_header) = HeaderValue::from_str(&stripped) + { to.headers_mut().insert(header::COOKIE, cookie_header); } } @@ -1515,6 +1879,7 @@ fn copy_request_headers( /// Appends query parameters to a URL, handling both URLs with and without existing query strings. /// Returns the original URL unchanged if params are empty or already present. +#[cfg(test)] fn append_query_params(url: &str, params: &str) -> String { if params.is_empty() || url.contains(params) { return url.to_string(); @@ -1526,30 +1891,309 @@ fn append_query_params(url: &str, params: &str) -> String { } } +/// Parse a planned PBS response with the configured profile behavior. +/// +/// This preserves the legacy PBS status, body, bid, cache, and metadata +/// semantics while allowing each planned provider to retain its own identity. +pub(crate) async fn parse_planned_prebid_response( + provider_id: &str, + profile: &PrebidProfilePlan, + input: &ProviderAuctionInput, + response: PlatformResponse, + response_time_ms: u64, + auction_id: &str, +) -> Result> { + let response = response.response; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "prebid", + ) + .await + .change_context(TrustedServerError::Prebid { + message: "Failed to read Prebid response body".to_string(), + }); + + if !status.is_success() { + log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); + let body_bytes = match body_bytes { + Ok(body_bytes) => Some(body_bytes), + Err(error) => { + log::warn!( + "Prebid auction {auction_id:?} failed to read non-success response body: {error:?}" + ); + None + } + }; + if profile.debug + && let Some(body_bytes) = body_bytes.as_deref() + { + match prebid_body_preview(body_bytes) { + Some(preview) => { + let truncation = if preview.truncated { + " (truncated)" + } else { + "" + }; + log::warn!( + "Prebid auction {auction_id:?} error response body preview{truncation}: {}", + preview.text + ); + } + None => log::warn!( + "Prebid auction {auction_id:?} returned an empty error response body" + ), + } + } + + let status_code = status.as_u16(); + let mut parsed = AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) + .with_metadata("http_status", serde_json::json!(status_code)) + .with_metadata( + "message", + serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), + ); + if profile.debug + && let Some(message) = body_bytes + .as_deref() + .and_then(|body| extract_prebid_error_message(body, content_type.as_deref())) + { + parsed.metadata.insert( + "upstream_message".to_string(), + serde_json::json!(message.text), + ); + parsed.metadata.insert( + "upstream_message_truncated".to_string(), + serde_json::json!(message.truncated), + ); + } + return Ok(parsed); + } + + let body_bytes = body_bytes?; + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { + message: "Failed to parse Prebid response".to_string(), + })?; + if profile.debug && log::log_enabled!(log::Level::Trace) { + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), + Err(error) => log::warn!("Prebid: failed to serialize response for logging: {error}"), + } + } + + let mut parsed = + parse_planned_prebid_openrtb(provider_id, input, &response_json, response_time_ms); + enrich_planned_prebid_metadata(profile, &response_json, &mut parsed); + log::info!( + "Prebid provider {provider_id} returned {} bids in {}ms", + parsed.bids.len(), + response_time_ms + ); + Ok(parsed) +} + +fn parse_planned_prebid_openrtb( + provider_id: &str, + input: &ProviderAuctionInput, + response_json: &Json, + response_time_ms: u64, +) -> AuctionResponse { + let mut bids = Vec::new(); + if let Some(seatbids) = response_json.get("seatbid").and_then(Json::as_array) { + for seatbid in seatbids { + let returned_seat = seatbid + .get("seat") + .and_then(Json::as_str) + .filter(|seat| !seat.is_empty()); + let delivery_bidder = returned_seat.unwrap_or("unknown"); + if let Some(entries) = seatbid.get("bid").and_then(Json::as_array) { + for entry in entries { + match parse_planned_prebid_bid(entry, delivery_bidder, returned_seat) { + Ok(bid) if planned_prebid_bid_is_allowed(&bid, input) => bids.push(bid), + Ok(_) => {} + Err(()) => { + let impression = entry + .get("impid") + .and_then(Json::as_str) + .unwrap_or(""); + log::warn!( + "Prebid: failed to parse bid from seat '{delivery_bidder}' for imp '{impression}'" + ); + } + } + } + } + } + } + if bids.is_empty() { + AuctionResponse::no_bid(provider_id, response_time_ms) + } else { + AuctionResponse::success(provider_id, bids, response_time_ms) + } +} + +fn enrich_planned_prebid_metadata( + profile: &PrebidProfilePlan, + response_json: &Json, + parsed: &mut AuctionResponse, +) { + let ext = response_json.get("ext"); + for key in ["responsetimemillis", "errors", "warnings"] { + if let Some(value) = ext.and_then(|ext| ext.get(key)) { + parsed.metadata.insert(key.to_string(), value.clone()); + } + } + if profile.debug { + if let Some(value) = ext.and_then(|ext| ext.get("debug")) { + parsed.metadata.insert("debug".to_string(), value.clone()); + } + if let Some(value) = ext + .and_then(|ext| ext.get("prebid")) + .and_then(|prebid| prebid.get("bidstatus")) + { + parsed + .metadata + .insert("bidstatus".to_string(), value.clone()); + } + } +} + +fn planned_prebid_bid_is_allowed(bid: &AuctionBid, input: &ProviderAuctionInput) -> bool { + input.slots().iter().any(|slot| { + slot.slot().id == bid.slot_id + && slot + .slot() + .formats + .iter() + .any(|format| (format.width, format.height) == (bid.width, bid.height)) + }) +} + +fn parse_planned_prebid_bid( + bid: &Json, + delivery_bidder: &str, + returned_seat: Option<&str>, +) -> Result { + let slot_id = bid + .get("impid") + .and_then(Json::as_str) + .ok_or(())? + .to_string(); + let price = bid + .get("price") + .and_then(Json::as_f64) + .filter(|price| price.is_finite() && *price >= 0.0) + .ok_or(())?; + let creative = bid.get("adm").and_then(Json::as_str).map(String::from); + let width = bid + .get("w") + .and_then(Json::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(0); + let height = bid + .get("h") + .and_then(Json::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(0); + let cache_entry = bid + .get("ext") + .and_then(|ext| ext.get("prebid")) + .and_then(|prebid| prebid.get("cache")) + .and_then(|cache| cache.get("bids")); + let cache_id = cache_entry + .and_then(|cache| cache.get("cacheId")) + .and_then(Json::as_str) + .map(String::from); + let (cache_host, cache_path) = cache_entry + .and_then(|cache| cache.get("url")) + .and_then(Json::as_str) + .and_then(|value| { + ParsedUrl::parse(value) + .map_err(|error| log::debug!("PBS cache URL parse failed: {error}")) + .ok() + }) + .map(|url| { + let host = url.host_str().map(String::from); + let path = url.path().to_string(); + let path = (!path.is_empty() && path != "/").then_some(path); + (host, path) + }) + .unwrap_or((None, None)); + if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — creative will fail to render for slot '{slot_id}'" + ); + } + + Ok(AuctionBid { + slot_id, + price: Some(price), + currency: DEFAULT_CURRENCY.to_string(), + creative, + adomain: bid.get("adomain").and_then(Json::as_array).map(|domains| { + domains + .iter() + .filter_map(Json::as_str) + .map(String::from) + .collect() + }), + bidder: delivery_bidder.to_string(), + returned_seat: returned_seat.map(str::to_string), + width, + height, + nurl: bid.get("nurl").and_then(Json::as_str).map(String::from), + burl: bid.get("burl").and_then(Json::as_str).map(String::from), + bid_id: bid + .get("id") + .and_then(Json::as_str) + .filter(|value| !value.is_empty()) + .map(String::from), + ad_id: bid.get("adid").and_then(Json::as_str).map(String::from), + creative_id: bid.get("crid").and_then(Json::as_str).map(String::from), + renderer: None, + cache_id, + cache_host, + cache_path, + metadata: HashMap::new(), + }) +} + // ============================================================================ // Prebid Auction Provider // ============================================================================ -/// Prebid Server auction provider. +/// Legacy Prebid Server auction provider retained only for parity tests. +#[cfg(test)] pub struct PrebidAuctionProvider { - config: PrebidIntegrationConfig, + config: LegacyPrebidServerConfig, bid_param_override_engine: Arc, } +#[cfg(test)] #[derive(Default)] struct PrebidImpressionDisposition { aps_only: usize, invalid: usize, } +#[cfg(test)] struct PrebidRequestBuild { request: OpenRtbRequest, disposition: PrebidImpressionDisposition, } +#[cfg(test)] impl PrebidAuctionProvider { #[cfg(test)] - fn new(config: PrebidIntegrationConfig) -> Self { + fn new(config: LegacyPrebidServerConfig) -> Self { Self::try_new(config).expect("should compile prebid bid param overrides") } @@ -1558,7 +2202,7 @@ impl PrebidAuctionProvider { /// # Errors /// /// Returns an error when the configured bidder-param override rules are invalid. - pub fn try_new(config: PrebidIntegrationConfig) -> Result> { + pub fn try_new(config: LegacyPrebidServerConfig) -> Result> { Ok(Self { bid_param_override_engine: Arc::new(BidParamOverrideEngine::try_from_config(&config)?), config, @@ -2435,6 +3079,7 @@ impl PrebidAuctionProvider { creative, adomain, bidder: seat.to_string(), + returned_seat: None, width, height, nurl, @@ -2451,9 +3096,10 @@ impl PrebidAuctionProvider { } } +#[cfg(test)] #[async_trait(?Send)] impl AuctionProvider for PrebidAuctionProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { PREBID_INTEGRATION_ID } @@ -2661,6 +3307,7 @@ impl AuctionProvider for PrebidAuctionProvider { /// /// Returns an error when the Prebid provider is enabled with invalid /// configuration. +#[cfg(test)] pub fn register_auction_provider( settings: &Settings, ) -> Result>, Report> { @@ -2669,10 +3316,12 @@ pub fn register_auction_provider( return Ok(Vec::new()); }; - log::info!( - "Registering Prebid auction provider (server_url={})", - integration.config.server_url - ); + if let Some(config) = integration.legacy_config.as_ref() { + log::info!( + "Registering Prebid auction provider (server_url={})", + config.server_url + ); + } if integration.config.debug { log::warn!( "Prebid debug mode is ON — debug data (httpcalls, resolvedrequest, \ @@ -2690,7 +3339,14 @@ mod tests { use super::*; use crate::auction::formats::convert_to_openrtb_response; use crate::auction::orchestrator::OrchestrationResult; - use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; + use crate::auction::plan::{ + AuctionPlanConfig, BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, + ProviderId, RoutingMode, + }; + use crate::auction::test_support::{ + canonical_parity_auction_request, + create_test_auction_context as shared_test_auction_context, + }; use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; @@ -2702,8 +3358,9 @@ mod tests { AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, }; use crate::platform::test_support::{ - NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient, - build_services_with_http_client, + HashMapConfigStore, HashMapSecretStore, NoopConfigStore, NoopGeo, NoopHttpClient, + NoopSecretStore, StubHttpClient, build_services_with_config_secret_and_http_client, + build_services_with_http_client, build_services_with_http_client_and_client_ip, }; use crate::platform::{ ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, RuntimeServices, @@ -2715,15 +3372,16 @@ mod tests { use bytes::Bytes; use http::Method; use serde_json::json; - use std::collections::HashMap; + use std::collections::{BTreeMap, HashMap}; use std::io::Cursor; + use std::str::FromStr as _; fn make_settings() -> Settings { create_test_settings() } - fn base_config() -> PrebidIntegrationConfig { - PrebidIntegrationConfig { + fn base_config() -> LegacyPrebidServerConfig { + LegacyPrebidServerConfig { enabled: true, server_url: "https://prebid.example".to_string(), account_id: Some("test-account".to_string()), @@ -2752,6 +3410,10 @@ mod tests { struct PredictOnlyBackend; impl PlatformBackend for PredictOnlyBackend { + fn naming_policy(&self) -> crate::platform::BackendNamingPolicy { + crate::platform::BackendNamingPolicy::Axum + } + fn predict_name( &self, spec: &PlatformBackendSpec, @@ -2917,6 +3579,7 @@ mod tests { settings: &settings, request: &http_req, timeout_ms: 500, + transport_timeout_ms: 500, provider_responses: None, services: &services, }; @@ -2963,6 +3626,7 @@ mod tests { settings: &settings, request: &http_req, timeout_ms: 500, + transport_timeout_ms: 500, provider_responses: None, services: &services, }; @@ -3025,23 +3689,23 @@ passphrase = "test-secret-key-32-bytes-minimum" "#; /// Parse a TOML string containing only the `[integrations.prebid]` section - /// (plus any sub-tables) into a [`PrebidIntegrationConfig`]. - fn parse_prebid_toml(prebid_section: &str) -> PrebidIntegrationConfig { + /// (plus any sub-tables) into a [`LegacyPrebidServerConfig`]. + fn parse_prebid_toml(prebid_section: &str) -> LegacyPrebidServerConfig { let toml_str = format!("{}{}", TOML_BASE, prebid_section); let settings = Settings::from_toml(&toml_str).expect("should parse TOML"); settings - .integration_config::("prebid") + .integration_config::("prebid") .expect("should get config") .expect("should be enabled") } fn parse_prebid_toml_result( prebid_section: &str, - ) -> Result> { + ) -> Result> { let toml_str = format!("{}{}", TOML_BASE, prebid_section); let settings = Settings::from_toml(&toml_str)?; settings - .integration_config::("prebid")? + .integration_config::("prebid")? .ok_or_else(|| { Report::new(TrustedServerError::Configuration { message: "prebid integration config should be present and enabled".to_string(), @@ -3068,6 +3732,7 @@ server_url = "https://prebid.example/openrtb2/auction" ); } + /* Legacy mixed-config canonicalization test replaced by browser-only registration tests. #[test] fn startup_validation_and_runtime_build_canonicalize_excluded_gam_ad_unit_path_suffixes() { let mut settings = make_settings(); @@ -3077,7 +3742,6 @@ server_url = "https://prebid.example/openrtb2/auction" PREBID_INTEGRATION_ID, &json!({ "enabled": true, - "server_url": "https://prebid.example/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "excluded_gam_ad_unit_path_suffixes": [ "/trackingonly", @@ -3119,6 +3783,7 @@ server_url = "https://prebid.example/openrtb2/auction" ); } + */ #[test] fn excluded_gam_ad_unit_path_suffixes_reject_invalid_values() { for (suffix, expected_message) in [ @@ -3189,16 +3854,21 @@ excluded_gam_ad_unit_path_suffixes = ["{suffix}"] "prebid", &json!({ "enabled": true, - "server_url": "https://test-prebid.com/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, - "bidders": ["mocktioneer"], "script_patterns": [], "debug": false }), ) .expect("should update prebid config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -3240,16 +3910,21 @@ excluded_gam_ad_unit_path_suffixes = ["{suffix}"] "prebid", &json!({ "enabled": true, - "server_url": "https://test-prebid.com/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, - "bidders": ["mocktioneer"], "script_patterns": ["/prebid.js", "/prebid.min.js"], "debug": false }), ) .expect("should update prebid config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -3443,15 +4118,20 @@ external_bundle_sri = "sha384-AAAA" "prebid", &json!({ "enabled": true, - "server_url": "https://prebid.example/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "external_bundle_sha256": "0".repeat(64) }), ) .expect("should update prebid config"); - let registry = IntegrationRegistry::new(&settings) - .expect("should create registry with valid SHA-256 and no SRI"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry with valid SHA-256 and no SRI"); assert!( registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), @@ -3468,7 +4148,6 @@ external_bundle_sri = "sha384-AAAA" "prebid", &json!({ "enabled": true, - "server_url": "https://prebid.example/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "external_bundle_sha256": "0".repeat(64), "external_bundle_sri": test_sri("sha384", &[0; 48]) @@ -3476,8 +4155,14 @@ external_bundle_sri = "sha384-AAAA" ) .expect("should update prebid config"); - let registry = IntegrationRegistry::new(&settings) - .expect("should create registry with valid SHA-256 and SHA-384 SRI"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry with valid SHA-256 and SHA-384 SRI"); assert!( registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), @@ -3485,6 +4170,7 @@ external_bundle_sri = "sha384-AAAA" ); } + /* Browser-only Prebid permits no managed external bundle URL. #[test] fn external_bundle_registration_requires_bundle_url() { let mut settings = make_settings(); @@ -3494,12 +4180,11 @@ external_bundle_sri = "sha384-AAAA" "prebid", &json!({ "enabled": true, - "server_url": "https://prebid.example/openrtb2/auction" }), ) .expect("should update prebid config"); - let err = match IntegrationRegistry::new(&settings) { + let err = match IntegrationRegistry::with_plan(&settings, Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile auction plan"))) { Ok(_) => panic!("should reject missing URL"), Err(err) => err, }; @@ -3509,6 +4194,7 @@ external_bundle_sri = "sha384-AAAA" ); } + */ #[test] fn external_bundle_registration_uses_proxy_allowed_domains() { let mut settings = make_settings(); @@ -3519,13 +4205,18 @@ external_bundle_sri = "sha384-AAAA" "prebid", &json!({ "enabled": true, - "server_url": "https://prebid.example/openrtb2/auction", "external_bundle_url": "https://blocked.example/prebid/trusted-prebid.js" }), ) .expect("should update prebid config"); - let err = match IntegrationRegistry::new(&settings) { + let err = match IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) { Ok(_) => panic!("should reject bundle host outside proxy.allowed_domains"), Err(err) => err, }; @@ -3833,6 +4524,7 @@ external_bundle_sri = "sha384-AAAA" ); } + /* Startup validation now uses the browser-only public config path. #[test] fn external_bundle_startup_validation_requires_proxy_allowed_domains() { let mut settings = make_settings(); @@ -3847,6 +4539,7 @@ external_bundle_sri = "sha384-AAAA" ); } + */ #[test] fn external_bundle_handler_fetches_and_sanitizes_with_platform_client() { futures::executor::block_on(async { @@ -4017,6 +4710,97 @@ external_bundle_sri = "sha384-AAAA" ); } + #[test] + #[allow(clippy::field_reassign_with_default)] + fn prepared_browser_injection_uses_only_plan_routes_and_browser_timeout_debug() { + let integration = PrebidIntegration::new(base_config()); + let mut browser_config = PrebidIntegrationConfig::default(); + browser_config.account_id = Some("browser-account".to_string()); + browser_config.timeout_ms = 1750; + browser_config.debug = false; + let plan = AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 2500, + providers: BTreeMap::from([ + ( + ProviderId::from_str("pbs-primary").expect("should parse provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "prebid-server".to_string(), + endpoint: "https://primary.example.test/openrtb".to_string(), + timeout_ms: Some(3000), + routing: RoutingMode::Explicit, + notifications: NotificationConfig::default(), + profile_config: json!({"debug": true}), + }, + ), + ( + ProviderId::from_str("pbs-secondary").expect("should parse provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "prebid-server".to_string(), + endpoint: "https://secondary.example.test/openrtb".to_string(), + timeout_ms: Some(4000), + routing: RoutingMode::Explicit, + notifications: NotificationConfig::default(), + profile_config: json!({"debug": true}), + }, + ), + ]), + bidders: BTreeMap::from([ + ( + BidderId::from_str("secondaryRoute").expect("should parse bidder ID"), + BidderRouteConfig { + provider: ProviderId::from_str("pbs-secondary") + .expect("should parse provider ID"), + }, + ), + ( + BidderId::from_str("primaryRoute").expect("should parse bidder ID"), + BidderRouteConfig { + provider: ProviderId::from_str("pbs-primary") + .expect("should parse provider ID"), + }, + ), + ]), + mediator: None, + request_signing: None, + }) + .expect("should compile plan while browser integration is not part of compilation"); + + let inserts = integration.head_inserts_for_plan(&browser_config, &plan); + let script = &inserts[0]; + + assert!(script.contains(r#""timeout":1750,"debug":false"#)); + assert!( + script.contains(r#""serverSideBidders":["primaryRoute","secondaryRoute"]"#), + "should inject deterministic browser route codes: {script}" + ); + assert!(!script.contains("pbs-primary")); + assert!(!script.contains("pbs-secondary")); + assert!(!script.contains("3000")); + assert!(!script.contains("4000")); + + let disabled_plan = plan.clone().with_enabled(false); + let disabled_inserts = integration.head_inserts_for_plan(&browser_config, &disabled_plan); + assert!( + disabled_inserts[0].contains(r#""serverSideBidders":[]"#), + "auction kill switch should suppress browser server-side bidders: {}", + disabled_inserts[0] + ); + } + + #[test] + fn browser_only_config_defaults_are_independent_and_can_be_disabled() { + let config = PrebidIntegrationConfig { + enabled: false, + ..PrebidIntegrationConfig::default() + }; + + assert!(!config.enabled); + assert_eq!(config.timeout_ms, 1000); + assert!(!config.debug); + } + #[test] fn head_injector_includes_excluded_gam_ad_unit_path_suffixes() { let mut config = base_config(); @@ -5859,7 +6643,7 @@ external_bundle_sri = "sha384-AAAA" } fn call_to_openrtb( - config: PrebidIntegrationConfig, + config: LegacyPrebidServerConfig, request: &AuctionRequest, ) -> OpenRtbRequest { use crate::platform::test_support::noop_services; @@ -5875,6 +6659,7 @@ external_bundle_sri = "sha384-AAAA" settings: &settings, request: &http_req, timeout_ms: 1000, + transport_timeout_ms: 1000, provider_responses: None, services: &services, }; @@ -6043,7 +6828,7 @@ set = {{ placementId = "13579" }} let runtime_settings = Settings::from_json_value(serialized).expect("should parse runtime JSON settings"); let config = runtime_settings - .integration_config::(PREBID_INTEGRATION_ID) + .integration_config::(PREBID_INTEGRATION_ID) .expect("should parse Prebid config") .expect("should enable Prebid config"); @@ -7361,6 +8146,282 @@ set = { networkId = 42 } ); } + fn planned_prebid_profile(debug: bool) -> PrebidProfilePlan { + PrebidProfilePlan { + debug, + test_mode: false, + debug_query_params: None, + override_engine: BidParamOverrideEngine::default(), + consent_forwarding: ConsentForwardingMode::default(), + } + } + + fn planned_prebid_input(slot_ids: &[&str]) -> ProviderAuctionInput { + let provider_id = ProviderId::from_str("pbs-instance").expect("should parse provider ID"); + let plan = crate::auction::plan::AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 1_000, + providers: BTreeMap::from([( + provider_id, + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "prebid-server".to_string(), + endpoint: "https://pbs.example/openrtb2/auction".to_string(), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: json!({}), + }, + )]), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, + }) + .expect("should compile planned PBS test plan"); + let inbound = http::Request::new(EdgeBody::empty()); + let request = make_auction_request( + slot_ids + .iter() + .map(|slot_id| make_slot(slot_id, HashMap::new())) + .collect(), + ); + crate::auction::routing::route_auction(request, &inbound, &plan, None) + .inputs() + .first() + .expect("should route planned PBS test slots") + .clone() + } + + #[test] + fn planned_parser_preserves_non_success_debug_and_204_json_parity() { + let profile = planned_prebid_profile(true); + let error = futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &profile, + &planned_prebid_input(&["fictional-slot"]), + prebid_platform_response( + StatusCode::BAD_GATEWAY, + Some("application/json"), + br#"{"errors":{"example":[{"message":" fictional rejection "}]}}"#.to_vec(), + ), + 42, + "auction-1", + )) + .expect("should classify non-success response"); + assert_eq!(error.provider, "pbs-instance"); + assert_eq!(error.status, crate::auction::types::BidStatus::Error); + assert_eq!(error.metadata["error_type"], ERROR_TYPE_HTTP_STATUS); + assert_eq!(error.metadata["http_status"], 502); + assert_eq!(error.metadata["upstream_message"], "fictional rejection"); + assert_eq!(error.metadata["upstream_message_truncated"], false); + + let no_content = futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &profile, + &planned_prebid_input(&["fictional-slot"]), + prebid_platform_response(StatusCode::NO_CONTENT, None, Vec::new()), + 7, + "auction-2", + )); + assert!( + no_content.is_err(), + "PBS must attempt JSON parsing for every successful status including 204" + ); + } + + #[test] + fn planned_parser_preserves_seat_identity_and_suppression() { + let profile = planned_prebid_profile(false); + let mut parsed = futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &profile, + &planned_prebid_input(&["literal", "missing", "non-string"]), + prebid_platform_response( + StatusCode::OK, + Some("application/json"), + br#"{"seatbid":[{"seat":"unknown","bid":[{"impid":"literal","price":1.0,"w":300,"h":250,"nurl":"https://notify.example/literal","burl":"https://notify.example/literal"}]},{"bid":[{"impid":"missing","price":2.0,"w":300,"h":250,"nurl":"https://notify.example/missing","burl":"https://notify.example/missing"}]},{"seat":4,"bid":[{"impid":"non-string","price":3.0,"w":300,"h":250,"nurl":"https://notify.example/non-string","burl":"https://notify.example/non-string"}]}]}"#.to_vec(), + ), + 8, + "auction-3", + )) + .expect("should parse seats and fallback delivery bidders"); + + assert_eq!(parsed.bids[0].bidder, "unknown"); + assert_eq!(parsed.bids[0].returned_seat.as_deref(), Some("unknown")); + for bid in &parsed.bids[1..] { + assert_eq!(bid.bidder, "unknown"); + assert!(bid.returned_seat.is_none()); + } + + crate::auction::openrtb::apply_notification_policy( + &mut parsed.bids, + &crate::auction::plan::NotificationPolicy { + suppress_all: false, + suppress_seats: std::collections::BTreeSet::from(["unknown".to_string()]), + }, + ); + assert!(parsed.bids[0].nurl.is_none()); + assert!(parsed.bids[0].burl.is_none()); + for bid in &parsed.bids[1..] { + assert!( + bid.nurl.is_some(), + "fallback bidder must not match seat suppression" + ); + assert!( + bid.burl.is_some(), + "fallback bidder must not match seat suppression" + ); + } + } + + #[test] + fn planned_parser_rejects_unrequested_mismatched_and_negative_bids() { + let profile = planned_prebid_profile(false); + let input = planned_prebid_input(&["requested"]); + let parsed = futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &profile, + &input, + prebid_platform_response( + StatusCode::OK, + Some("application/json"), + br#"{"seatbid":[{"bid":[{"impid":"requested","price":1.0,"w":300,"h":250},{"impid":"unrequested","price":2.0,"w":300,"h":250},{"impid":"requested","price":3.0,"w":1,"h":1},{"impid":"requested","price":-1.0,"w":300,"h":250}]}]}"#.to_vec(), + ), + 42, + "auction-validation", + )) + .expect("should parse planned PBS response"); + + assert_eq!(parsed.bids.len(), 1, "should retain only the admitted bid"); + assert_eq!(parsed.bids[0].slot_id, "requested"); + assert_eq!(parsed.bids[0].price, Some(1.0)); + } + + #[test] + fn planned_parser_matches_legacy_error_content_type_and_debug_metadata() { + let debug_profile = planned_prebid_profile(true); + for (content_type, body, expected_message) in [ + ( + Some("application/json"), + br#"{"message":" JSON rejection "}"#.as_slice(), + Some("JSON rejection"), + ), + ( + Some("text/plain; charset=utf-8"), + b" plain text rejection ".as_slice(), + Some("plain text rejection"), + ), + ( + Some("text/html"), + b"proxy failure".as_slice(), + None, + ), + ( + Some("application/octet-stream"), + b"unsupported plain text".as_slice(), + None, + ), + ] { + let parsed = futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &debug_profile, + &planned_prebid_input(&["fictional-slot"]), + prebid_platform_response(StatusCode::BAD_REQUEST, content_type, body.to_vec()), + 42, + "auction-content-type", + )) + .expect("should classify non-success response"); + assert_eq!( + parsed + .metadata + .get("upstream_message") + .and_then(Json::as_str), + expected_message, + "should match legacy error content-type handling" + ); + } + + let no_debug = planned_prebid_profile(false); + let parsed = futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &no_debug, + &planned_prebid_input(&["fictional-slot"]), + prebid_platform_response( + StatusCode::BAD_REQUEST, + Some("application/json"), + br#"{"message":"must stay hidden"}"#.to_vec(), + ), + 42, + "auction-no-debug", + )) + .expect("should classify non-success response without debug metadata"); + assert!( + !parsed.metadata.contains_key("upstream_message") + && !parsed.metadata.contains_key("upstream_message_truncated"), + "debug-disabled planned PBS must not expose upstream error metadata" + ); + } + + #[test] + fn planned_parser_bounds_oversized_success_and_error_bodies() { + let profile = planned_prebid_profile(true); + for response in [ + prebid_platform_response( + StatusCode::OK, + Some("application/json"), + vec![b'x'; UPSTREAM_RTB_MAX_RESPONSE_BYTES + 1], + ), + prebid_platform_response_with_body( + StatusCode::OK, + Some("application/json"), + EdgeBody::stream(futures::stream::iter([ + Bytes::from(vec![b'x'; UPSTREAM_RTB_MAX_RESPONSE_BYTES]), + Bytes::from_static(b"x"), + ])), + ), + ] { + assert!( + futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &profile, + &planned_prebid_input(&["fictional-slot"]), + response, + 42, + "auction-oversized-success", + )) + .is_err(), + "oversized successful planned PBS responses must fail bounded collection" + ); + } + + for response in [ + prebid_platform_response( + StatusCode::BAD_GATEWAY, + Some("text/plain"), + vec![b'x'; UPSTREAM_RTB_MAX_RESPONSE_BYTES + 1], + ), + prebid_platform_response_with_body( + StatusCode::SERVICE_UNAVAILABLE, + Some("text/plain"), + EdgeBody::stream(futures::stream::iter([ + Bytes::from(vec![b'x'; UPSTREAM_RTB_MAX_RESPONSE_BYTES]), + Bytes::from_static(b"x"), + ])), + ), + ] { + let expected_status = response.response.status().as_u16(); + let parsed = futures::executor::block_on(parse_planned_prebid_response( + "pbs-instance", + &profile, + &planned_prebid_input(&["fictional-slot"]), + response, + 42, + "auction-oversized-error", + )) + .expect("should preserve HTTP classification for oversized planned error body"); + assert_oversized_http_error_is_classified(&parsed, expected_status); + } + } + #[test] fn parse_bid_extracts_cache_id_from_ext_prebid_cache_bids() { let bid_json = serde_json::json!({ @@ -7494,7 +8555,7 @@ set = { networkId = 42 } "nurl": "https://ssp.example/win?id=abc123", "burl": "https://ssp.example/bill?id=abc123" }); - let config = PrebidIntegrationConfig { + let config = LegacyPrebidServerConfig { suppress_nurl: true, ..base_config() }; @@ -7522,7 +8583,7 @@ set = { networkId = 42 } "nurl": "https://ssp.example/win?id=abc123", "burl": "https://ssp.example/bill?id=abc123" }); - let config = PrebidIntegrationConfig { + let config = LegacyPrebidServerConfig { suppress_nurl_bidders: vec!["appnexus".to_string()], ..base_config() }; @@ -7612,6 +8673,10 @@ set = { networkId = 42 } let bid = provider .parse_bid(&bid_json, "example-bidder") .expect("should parse bid"); + assert!( + bid.returned_seat.is_none(), + "legacy PBS parsing must not attach planned telemetry identity" + ); assert_eq!( bid.bid_id.as_deref(), Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"), @@ -7645,6 +8710,64 @@ set = { networkId = 42 } ); } + #[test] + fn planned_transport_preserves_malformed_cookie_bytes_and_attested_xff() { + let inbound = http::Request::builder() + .uri("https://publisher.example/auction") + .header( + header::COOKIE, + HeaderValue::from_bytes(b"session=fictional;\xffbroken") + .expect("should build malformed cookie header"), + ) + .header(header::USER_AGENT, "Fictional Browser/2") + .header("x-forwarded-for", "198.51.100.8") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let plan = + crate::auction::plan::AuctionPlan::compile(crate::auction::plan::AuctionPlanConfig { + timeout_ms: 321, + providers: std::collections::BTreeMap::new(), + bidders: std::collections::BTreeMap::new(), + mediator: None, + request_signing: None, + }) + .expect("should compile empty plan"); + let routed = crate::auction::routing::route_auction( + canonical_parity_auction_request(), + &inbound, + &plan, + Some(std::net::IpAddr::from([203, 0, 113, 9])), + ); + let mut outbound = http::Request::builder() + .uri("https://pbs.example.test/openrtb2/auction") + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + apply_prebid_transport_headers( + routed.prebid_transport_headers(), + &mut outbound, + ConsentForwardingMode::OpenrtbOnly, + routed.attested_client_ip(), + ); + + assert_eq!( + outbound + .headers() + .get(header::COOKIE) + .map(HeaderValue::as_bytes), + Some(b"session=fictional;\xffbroken".as_slice()), + "should forward malformed cookie bytes unchanged" + ); + assert_eq!( + outbound + .headers() + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()), + Some("203.0.113.9"), + "should use only attested XFF" + ); + } + #[test] fn copy_request_headers_replaces_client_supplied_xff_with_attested_ip() { let from = http::Request::builder() @@ -7700,4 +8823,194 @@ set = { networkId = 42 } "should not forward the client-supplied XFF when no attested IP exists" ); } + + fn header_value<'a>(headers: &'a [(String, Vec)], name: &str) -> Option<&'a [u8]> { + headers + .iter() + .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_slice()) + } + + #[test] + fn dispatch_preserves_pbs_transport_header_and_cookie_matrix() { + for (mode, cookie, expected_cookie) in [ + ( + ConsentForwardingMode::Both, + HeaderValue::from_static("session=fictional; euconsent-v2=fictional-tcf"), + Some(b"session=fictional; euconsent-v2=fictional-tcf".as_slice()), + ), + ( + ConsentForwardingMode::CookiesOnly, + HeaderValue::from_static("session=fictional; euconsent-v2=fictional-tcf"), + Some(b"session=fictional; euconsent-v2=fictional-tcf".as_slice()), + ), + ( + ConsentForwardingMode::OpenrtbOnly, + HeaderValue::from_static("session=fictional; euconsent-v2=fictional-tcf"), + Some(b"session=fictional".as_slice()), + ), + ( + ConsentForwardingMode::OpenrtbOnly, + HeaderValue::from_static("euconsent-v2=fictional-tcf; us_privacy=1YNN"), + None, + ), + ( + ConsentForwardingMode::OpenrtbOnly, + HeaderValue::from_bytes(b"session=fictional;\xffbroken") + .expect("should build malformed cookie header"), + Some(b"session=fictional;\xffbroken".as_slice()), + ), + ] { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(204, Vec::new()); + let services = build_services_with_http_client_and_client_ip( + Arc::clone(&stub) as Arc, + std::net::IpAddr::from([203, 0, 113, 9]), + ); + let settings = make_settings(); + let mut config = base_config(); + config.consent_forwarding = mode; + let provider = PrebidAuctionProvider::new(config); + let auction_request = create_test_auction_request(); + let inbound = http::Request::builder() + .uri("https://pub.example/auction") + .header( + header::REFERER, + "https://referrer.example/story?fictional=1", + ) + .header(header::USER_AGENT, "Fictional Browser/1.0") + .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header("x-forwarded-for", "198.51.100.8") + .header(header::COOKIE, cookie) + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 321, + transport_timeout_ms: 321, + provider_responses: None, + services: &services, + }; + + futures::executor::block_on(provider.request_bids(&auction_request, &context)) + .expect("should dispatch PBS request"); + + let headers = stub.recorded_request_header_bytes(); + assert_eq!(headers.len(), 1, "should dispatch one request"); + let headers = &headers[0]; + assert_eq!( + header_value(headers, "referer"), + Some(b"https://referrer.example/story?fictional=1".as_slice()) + ); + assert_eq!( + header_value(headers, "user-agent"), + Some(b"Fictional Browser/1.0".as_slice()) + ); + assert_eq!( + header_value(headers, "accept-language"), + Some(b"en-US,en;q=0.9".as_slice()) + ); + assert_eq!( + header_value(headers, "x-forwarded-for"), + Some(b"203.0.113.9".as_slice()), + "should replace spoofable XFF with platform-attested IP" + ); + assert_eq!(header_value(headers, "cookie"), expected_cookie); + + let body = stub + .recorded_request_bodies() + .into_iter() + .next() + .expect("should capture PBS request body"); + let body: Json = serde_json::from_slice(&body).expect("should parse PBS request body"); + assert_eq!( + body["site"]["ref"], "https://referrer.example/story?fictional=1", + "should place the raw Referer in PBS site.ref as well as forwarding it" + ); + } + } + + #[test] + fn pbs_request_serialization_goldens_cover_disabled_and_deterministic_enabled_signing() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = canonical_parity_auction_request(); + let settings = make_settings(); + let inbound = http::Request::builder() + .uri("https://edge.example/auction") + .header( + header::REFERER, + "https://referrer.example/story?fictional=1", + ) + .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let services = crate::platform::test_support::noop_services(); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 321, + transport_timeout_ms: 321, + provider_responses: None, + services: &services, + }; + let request_info = RequestInfo { + host: "publisher.example".to_string(), + scheme: "https".to_string(), + }; + let disabled = provider.to_openrtb(&auction_request, &context, None, request_info.clone()); + let disabled = + serde_json::to_string(&disabled).expect("should serialize disabled PBS request"); + let disabled_value: Json = + serde_json::from_str(&disabled).expect("should parse disabled PBS golden request"); + assert_eq!( + disabled_value["user"]["ext"]["ConsentedProvidersSettings"]["consented_providers"], + json!("fictional-ac"), + "should retain Google Additional Consent only in PBS's existing extension" + ); + assert_eq!( + disabled, + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"request_host":"publisher.example","request_scheme":"https"}}}"#, + "should preserve the complete disabled PBS wire shape" + ); + + let mut config_data = HashMap::new(); + config_data.insert("current-kid".to_string(), "fictional-kid".to_string()); + let mut secret_data = HashMap::new(); + secret_data.insert( + "fictional-kid".to_string(), + base64::engine::general_purpose::STANDARD + .encode([7_u8; 32]) + .into_bytes(), + ); + let signing_services = build_services_with_config_secret_and_http_client( + HashMapConfigStore::new(config_data), + HashMapSecretStore::new(secret_data), + Arc::new(NoopHttpClient), + ); + let signer = RequestSigner::from_services(&signing_services) + .expect("should load deterministic test signer"); + let signing = SigningParams { + request_id: "fictional-auction".to_string(), + request_host: "publisher.example".to_string(), + request_scheme: "https".to_string(), + timestamp: 1_706_900_000, + }; + let signature = signer + .sign_request(&signing) + .expect("should sign deterministic PBS request"); + let enabled = provider.to_openrtb( + &auction_request, + &context, + Some((&signer, signature, &signing)), + request_info, + ); + let enabled = + serde_json::to_string(&enabled).expect("should serialize enabled PBS request"); + assert_eq!( + enabled, + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, + "should preserve the complete enabled PBS wire shape" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 0644fb522..7c0b6ef36 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -8,6 +8,7 @@ use error_stack::Report; use http::{Method, Request, Response}; use matchit::Router; +use crate::auction::AuctionPlan; use crate::constants::HEADER_X_TS_EC; use crate::ec::EcContext; use crate::ec::kv::KvIdentityGraph; @@ -772,6 +773,7 @@ pub struct ProxyDispatchInput<'a> { #[derive(Clone, Default)] pub struct IntegrationRegistry { inner: Arc, + plan: Option>, } impl IntegrationRegistry { @@ -784,89 +786,114 @@ impl IntegrationRegistry { /// # Panics /// /// Panics if a route path ends with `/*` but `strip_suffix` unexpectedly fails (invariant violation). - pub fn new(settings: &Settings) -> Result> { + pub fn with_plan( + settings: &Settings, + plan: Arc, + ) -> Result> { let mut inner = IntegrationRegistryInner::default(); - + let mut registrations = Vec::new(); + if let Some(registration) = crate::integrations::prebid::register_for_plan(settings, &plan)? + { + registrations.push(registration); + } + if let Some(registration) = crate::integrations::aps::register_for_plan(&plan) { + registrations.push(registration); + } for builder in crate::integrations::builders() { if let Some(registration) = (builder.build)(settings)? { - debug_assert_eq!( - registration.integration_id, builder.id, - "integration builder ID should match registration ID" - ); - inner - .enabled_integration_ids - .push(registration.integration_id); - - for proxy in registration.proxies { - for route in proxy.routes() { - let value = (proxy.clone(), registration.integration_id); - - // Convert /* wildcard to matchit's {*rest} syntax - let matchit_path = if route.path.ends_with("/*") { - format!( - "{}/{{*rest}}", - route - .path - .strip_suffix("/*") - .expect("path should end with '/*'") - ) - } else { - route.path.clone() - }; - - // Select appropriate router and insert - let router = match route.method { - Method::GET => &mut inner.get_router, - Method::POST => &mut inner.post_router, - Method::PUT => &mut inner.put_router, - Method::DELETE => &mut inner.delete_router, - Method::PATCH => &mut inner.patch_router, - Method::HEAD => &mut inner.head_router, - Method::OPTIONS => &mut inner.options_router, - _ => { - log::warn!( - "Unsupported HTTP method {} for route {}", - route.method, - route.path - ); - continue; - } - }; - - if let Err(e) = router.insert(&matchit_path, value) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Integration route registration failed for {} {}: {:?}", - route.method, route.path, e - ), - })); - } + debug_assert_eq!(registration.integration_id, builder.id); + registrations.push(registration); + } + } - inner.routes.push((route, registration.integration_id)); + for registration in registrations { + let builder_id = registration.integration_id; + debug_assert_eq!( + registration.integration_id, builder_id, + "integration builder ID should match registration ID" + ); + inner + .enabled_integration_ids + .push(registration.integration_id); + + for proxy in registration.proxies { + for route in proxy.routes() { + let value = (proxy.clone(), registration.integration_id); + + // Convert /* wildcard to matchit's {*rest} syntax + let matchit_path = if route.path.ends_with("/*") { + format!( + "{}/{{*rest}}", + route + .path + .strip_suffix("/*") + .expect("path should end with '/*'") + ) + } else { + route.path.clone() + }; + + // Select appropriate router and insert + let router = match route.method { + Method::GET => &mut inner.get_router, + Method::POST => &mut inner.post_router, + Method::PUT => &mut inner.put_router, + Method::DELETE => &mut inner.delete_router, + Method::PATCH => &mut inner.patch_router, + Method::HEAD => &mut inner.head_router, + Method::OPTIONS => &mut inner.options_router, + _ => { + log::warn!( + "Unsupported HTTP method {} for route {}", + route.method, + route.path + ); + continue; + } + }; + + if let Err(e) = router.insert(&matchit_path, value) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Integration route registration failed for {} {}: {:?}", + route.method, route.path, e + ), + })); } - } - inner - .html_rewriters - .extend(registration.attribute_rewriters); - inner.script_rewriters.extend(registration.script_rewriters); - inner - .html_post_processors - .extend(registration.html_post_processors); - inner.head_injectors.extend(registration.head_injectors); - inner.request_filters.extend(registration.request_filters); - if registration.js_disabled { - inner.disabled_js_ids.push(registration.integration_id); - } else if registration.js_deferred { - inner.deferred_js_ids.push(registration.integration_id); + + inner.routes.push((route, registration.integration_id)); } } + inner + .html_rewriters + .extend(registration.attribute_rewriters); + inner.script_rewriters.extend(registration.script_rewriters); + inner + .html_post_processors + .extend(registration.html_post_processors); + inner.head_injectors.extend(registration.head_injectors); + inner.request_filters.extend(registration.request_filters); + if registration.js_disabled { + inner.disabled_js_ids.push(registration.integration_id); + } else if registration.js_deferred { + inner.deferred_js_ids.push(registration.integration_id); + } } Ok(Self { inner: Arc::new(inner), + plan: Some(plan), }) } + /// Return whether this registry and another consumer share the same plan allocation. + #[must_use] + pub fn shares_plan(&self, plan: &Arc) -> bool { + self.plan + .as_ref() + .is_some_and(|owned| Arc::ptr_eq(owned, plan)) + } + fn find_route(&self, method: &Method, path: &str) -> Option<&RouteValue> { let router = match *method { Method::GET => &self.inner.get_router, @@ -1163,6 +1190,7 @@ impl IntegrationRegistry { pub fn empty_for_tests() -> Self { Self { inner: Arc::new(IntegrationRegistryInner::default()), + plan: None, } } @@ -1191,6 +1219,7 @@ impl IntegrationRegistry { deferred_js_ids: Vec::new(), disabled_js_ids: Vec::new(), }), + plan: None, } } @@ -1220,6 +1249,7 @@ impl IntegrationRegistry { deferred_js_ids: Vec::new(), disabled_js_ids: Vec::new(), }), + plan: None, } } @@ -1245,6 +1275,7 @@ impl IntegrationRegistry { deferred_js_ids: Vec::new(), disabled_js_ids: Vec::new(), }), + plan: None, } } @@ -1310,6 +1341,7 @@ impl IntegrationRegistry { deferred_js_ids: Vec::new(), disabled_js_ids: Vec::new(), }), + plan: None, } } } @@ -1964,17 +1996,21 @@ mod tests { "prebid", &serde_json::json!({ "enabled": true, - "server_url": "https://test-prebid.com/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, - "bidders": ["mocktioneer"], "debug": false }), ) .expect("should insert prebid config"); - let registry = - IntegrationRegistry::new(&settings_with_prebid).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings_with_prebid, + Arc::new( + crate::auction::compile_auction_plan(&settings_with_prebid) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let all = registry.js_module_ids(); let immediate = registry.js_module_ids_immediate(); @@ -2010,7 +2046,14 @@ mod tests { .insert_config("nextjs", &serde_json::json!({ "enabled": true })) .expect("should insert nextjs config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let all = registry.js_module_ids(); assert!( @@ -2039,7 +2082,14 @@ mod tests { .insert_config("osano", &serde_json::json!({ "enabled": true })) .expect("should insert osano config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let immediate = registry.js_module_ids_immediate(); assert!( @@ -2067,13 +2117,19 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let deferred = registry.js_module_ids_deferred(); assert!( @@ -2091,13 +2147,19 @@ mod tests { "prebid", &serde_json::json!({ "enabled": true, - "server_url": "https://test-prebid.com/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" }), ) .expect("should update prebid config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); assert!( registry.js_module_ids().contains(&"prebid"), @@ -2127,17 +2189,21 @@ mod tests { "prebid", &serde_json::json!({ "enabled": true, - "server_url": "https://test-prebid.com/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, - "bidders": ["mocktioneer"], "debug": false }), ) .expect("should insert prebid config"); - let registry = - IntegrationRegistry::new(&settings_with_prebid).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings_with_prebid, + Arc::new( + crate::auction::compile_auction_plan(&settings_with_prebid) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); let all = registry.js_module_ids(); let mut recombined = registry.js_module_ids_immediate(); diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index 3caaadeef..7b2e49fb0 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -1396,7 +1396,14 @@ mod tests { .insert_config(SOURCEPOINT_INTEGRATION_ID, &json!({ "enabled": true })) .expect("should insert config"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); for method in [Method::GET, Method::POST, Method::HEAD, Method::OPTIONS] { assert!( registry.has_route(&method, "/integrations/sourcepoint/cdn/wrapper/v2/messages"), diff --git a/crates/trusted-server-core/src/platform/backend_naming.rs b/crates/trusted-server-core/src/platform/backend_naming.rs new file mode 100644 index 000000000..b44027d7b --- /dev/null +++ b/crates/trusted-server-core/src/platform/backend_naming.rs @@ -0,0 +1,563 @@ +//! Pure adapter backend naming and auction-target capability policies. +//! +//! These policies contain no platform SDK calls. Startup validation, CLI +//! validation, and runtime adapters can therefore predict the same backend +//! names before any backend registration occurs. + +use core::fmt::Write as _; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::PlatformBackendSpec; +use crate::host_header::validate_host_header_override_value; + +const MAX_FASTLY_BACKEND_NAME_LEN: usize = 255; +const MAX_FASTLY_READABLE_PREFIX_LEN: usize = 200; +const FASTLY_SPEC_DIGEST_HEX_LEN: usize = 32; +const FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; +const FASTLY_TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS: u32 = 2000; +const FASTLY_SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; +const FASTLY_TRANSPORT_TIMEOUT_COARSE_LADDER_MS: [u32; 8] = + [2000, 3000, 5000, 10000, 20000, 30000, 45000, 60000]; + +/// A pure backend-name and transport-timeout policy for one adapter. +/// +/// Cloudflare and Spin intentionally remain separate variants even though +/// their current no-registration name formats are identical. Keeping distinct +/// policies prevents a future adapter-specific change from silently affecting +/// the other target. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum BackendNamingPolicy { + /// Fastly dynamic backend naming and bounded timeout buckets. + Fastly, + /// Axum's environment-segment-compatible correlation name. + Axum, + /// Cloudflare's deterministic no-registration correlation name. + Cloudflare, + /// Spin's deterministic no-registration correlation name. + Spin, +} + +/// Pure backend prediction shared by validation and runtime registration. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct PredictedBackend { + /// Deterministic platform backend or correlation name. + pub name: String, + /// Explicit or scheme-derived target port. + pub port: u16, +} + +/// Detailed pure backend prediction error. +#[derive(Debug, Clone, Eq, PartialEq, derive_more::Display)] +pub enum BackendNamingError { + /// The backend host is empty. + #[display("missing host")] + MissingHost, + /// A field contains a control character. + #[display("{field} contains control characters")] + ControlCharacters { field: &'static str }, + /// An outbound Host override is invalid. + #[display("host header override {reason}")] + InvalidHostHeaderOverride { reason: &'static str }, + /// A generated Fastly backend name exceeded its documented limit. + #[display("backend name exceeds {limit}-char limit ({actual} chars)")] + NameTooLong { limit: usize, actual: usize }, +} + +impl core::error::Error for BackendNamingError {} + +impl BackendNamingPolicy { + /// Predict a backend name and resolved port without platform I/O. + /// + /// # Errors + /// + /// Returns a naming error when a Fastly backend specification contains an + /// invalid host, scheme, Host override, or cannot fit the platform limit. + pub fn predict( + self, + spec: &PlatformBackendSpec, + ) -> Result> { + match self { + Self::Fastly => predict_fastly(spec), + Self::Axum => Ok(predict_axum(spec)), + Self::Cloudflare => Ok(predict_cloudflare(spec)), + Self::Spin => Ok(predict_spin(spec)), + } + } + + /// Canonicalize a transport timer without changing the logical budget. + /// + /// The result configures adapter transport timers and backend-name + /// stability only. It is not an auction-wide deadline. Fastly bounds the + /// cardinality of budget-derived timers because timers are encoded in + /// dynamic backend names; other adapters preserve the exact bounded value. + #[must_use] + pub fn canonicalize_transport_timeout_ms(self, remaining_ms: u32, configured_ms: u32) -> u32 { + match self { + Self::Fastly => canonicalize_fastly_transport_timeout_ms(remaining_ms, configured_ms), + Self::Axum | Self::Cloudflare | Self::Spin => remaining_ms.min(configured_ms), + } + } +} + +/// Canonical adapter target identifier accepted by Trusted Server tooling. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum AuctionTargetId { + /// Fastly Compute. + Fastly, + /// Native Axum development server. + Axum, + /// Cloudflare Workers. + Cloudflare, + /// Fermyon Spin. + Spin, +} + +impl AuctionTargetId { + /// Map a canonical `EdgeZero` adapter registry ID to an auction target. + #[must_use] + pub fn from_adapter_id(adapter_id: &str) -> Option { + match adapter_id { + "fastly" => Some(Self::Fastly), + "axum" => Some(Self::Axum), + "cloudflare" => Some(Self::Cloudflare), + "spin" => Some(Self::Spin), + _ => None, + } + } + + /// Return the canonical `EdgeZero` adapter registry ID. + #[must_use] + pub const fn adapter_id(self) -> &'static str { + match self { + Self::Fastly => "fastly", + Self::Axum => "axum", + Self::Cloudflare => "cloudflare", + Self::Spin => "spin", + } + } + + /// Return the shared naming and capability descriptor for this target. + #[must_use] + pub const fn descriptor(self) -> AuctionTargetDescriptor { + match self { + Self::Fastly => AuctionTargetDescriptor { + id: self, + naming_policy: BackendNamingPolicy::Fastly, + capabilities: AuctionTargetCapabilities { + concurrent_provider_fanout: true, + enforceable_total_request_deadline: false, + }, + }, + Self::Axum => AuctionTargetDescriptor { + id: self, + naming_policy: BackendNamingPolicy::Axum, + capabilities: AuctionTargetCapabilities { + concurrent_provider_fanout: true, + enforceable_total_request_deadline: false, + }, + }, + Self::Cloudflare => AuctionTargetDescriptor { + id: self, + naming_policy: BackendNamingPolicy::Cloudflare, + capabilities: AuctionTargetCapabilities { + concurrent_provider_fanout: false, + enforceable_total_request_deadline: false, + }, + }, + Self::Spin => AuctionTargetDescriptor { + id: self, + naming_policy: BackendNamingPolicy::Spin, + capabilities: AuctionTargetCapabilities { + concurrent_provider_fanout: false, + enforceable_total_request_deadline: false, + }, + }, + } + } +} + +/// Adapter capabilities relevant to auction dispatch validation. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct AuctionTargetCapabilities { + concurrent_provider_fanout: bool, + enforceable_total_request_deadline: bool, +} + +impl AuctionTargetCapabilities { + /// Return whether multiple provider requests can be in flight concurrently. + #[must_use] + pub const fn supports_concurrent_provider_fanout(self) -> bool { + self.concurrent_provider_fanout + } + + /// Return whether the adapter enforces a total request deadline per provider. + /// + /// Transport first-byte or between-byte timers do not satisfy this + /// capability because they do not cap the complete request lifetime. + #[must_use] + pub const fn has_enforceable_total_request_deadline(self) -> bool { + self.enforceable_total_request_deadline + } +} + +/// Shared target descriptor used by plan validation. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct AuctionTargetDescriptor { + id: AuctionTargetId, + naming_policy: BackendNamingPolicy, + capabilities: AuctionTargetCapabilities, +} + +impl AuctionTargetDescriptor { + /// Return the canonical target identity. + #[must_use] + pub const fn id(self) -> AuctionTargetId { + self.id + } + + /// Return the pure backend naming and transport timer policy. + #[must_use] + pub const fn naming_policy(self) -> BackendNamingPolicy { + self.naming_policy + } + + /// Return this target's auction dispatch capabilities. + #[must_use] + pub const fn capabilities(self) -> AuctionTargetCapabilities { + self.capabilities + } +} + +fn default_port(scheme: &str, https_case_insensitive: bool) -> u16 { + let https = if https_case_insensitive { + scheme.eq_ignore_ascii_case("https") + } else { + scheme == "https" + }; + if https { 443 } else { 80 } +} + +fn sanitize_fastly_component(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '_' + } + }) + .collect() +} + +fn fastly_canonical_spec(spec: &PlatformBackendSpec, target_port: u16) -> String { + fn push_field(buffer: &mut String, field: &str) { + buffer.push_str(&field.len().to_string()); + buffer.push(':'); + buffer.push_str(field); + } + + let mut buffer = String::new(); + push_field(&mut buffer, &spec.scheme); + push_field(&mut buffer, &spec.host); + push_field(&mut buffer, &target_port.to_string()); + push_field(&mut buffer, if spec.certificate_check { "1" } else { "0" }); + match spec.host_header_override.as_deref() { + Some(value) => { + buffer.push('s'); + push_field(&mut buffer, value); + } + None => buffer.push('n'), + } + match spec.discriminator.as_deref() { + Some(value) => { + buffer.push('s'); + push_field(&mut buffer, value); + } + None => buffer.push('n'), + } + push_field( + &mut buffer, + &spec.first_byte_timeout.as_millis().to_string(), + ); + push_field( + &mut buffer, + &spec.between_bytes_timeout.as_millis().to_string(), + ); + buffer +} + +fn fastly_spec_digest(canonical: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let mut hex = String::with_capacity(FASTLY_SPEC_DIGEST_HEX_LEN); + for byte in digest.iter().take(FASTLY_SPEC_DIGEST_HEX_LEN / 2) { + write!(hex, "{byte:02x}").expect("should write hex digit to string"); + } + hex +} + +fn predict_fastly( + spec: &PlatformBackendSpec, +) -> Result> { + if spec.host.is_empty() { + return Err(Report::new(BackendNamingError::MissingHost)); + } + if spec.host.chars().any(char::is_control) { + return Err(Report::new(BackendNamingError::ControlCharacters { + field: "host", + })); + } + if spec.scheme.chars().any(char::is_control) { + return Err(Report::new(BackendNamingError::ControlCharacters { + field: "scheme", + })); + } + if let Some(host_header_override) = spec.host_header_override.as_deref() { + validate_host_header_override_value(host_header_override).map_err(|reason| { + Report::new(BackendNamingError::InvalidHostHeaderOverride { reason }) + })?; + } + + let port = spec + .port + .unwrap_or_else(|| default_port(&spec.scheme, true)); + let name_base = format!("{}_{}_{}", spec.scheme, spec.host, port); + let host_override_suffix = spec + .host_header_override + .as_deref() + .map(|host| format!("_oh_{}", sanitize_fastly_component(host))) + .unwrap_or_default(); + let cert_suffix = if spec.certificate_check { + "" + } else { + "_nocert" + }; + let discriminator_suffix = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{}", sanitize_fastly_component(value))) + .unwrap_or_default(); + let readable_full = format!( + "{}{}{}{}_fb{}_bb{}", + sanitize_fastly_component(&name_base), + host_override_suffix, + cert_suffix, + discriminator_suffix, + spec.first_byte_timeout.as_millis(), + spec.between_bytes_timeout.as_millis() + ); + let readable = readable_full + .chars() + .take(MAX_FASTLY_READABLE_PREFIX_LEN) + .collect::(); + let digest = fastly_spec_digest(&fastly_canonical_spec(spec, port)); + let name = format!("backend_{readable}_{digest}"); + if name.len() > MAX_FASTLY_BACKEND_NAME_LEN { + return Err(Report::new(BackendNamingError::NameTooLong { + limit: MAX_FASTLY_BACKEND_NAME_LEN, + actual: name.len(), + })); + } + + Ok(PredictedBackend { name, port }) +} + +fn normalize_axum_segment(value: &str) -> String { + value.to_uppercase().replace(['-', '.', ' '], "_") +} + +fn predict_axum(spec: &PlatformBackendSpec) -> PredictedBackend { + let port = spec + .port + .unwrap_or_else(|| default_port(&spec.scheme, false)); + let discriminator = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{}", normalize_axum_segment(value))) + .unwrap_or_default(); + PredictedBackend { + name: format!( + "{}_{}_{}{}", + normalize_axum_segment(&spec.scheme), + normalize_axum_segment(&spec.host), + port, + discriminator + ), + port, + } +} + +fn predict_no_registration(spec: &PlatformBackendSpec) -> PredictedBackend { + let port = spec + .port + .unwrap_or_else(|| default_port(&spec.scheme, false)); + let cert_suffix = if spec.certificate_check { + "" + } else { + "_nocert" + }; + let discriminator = spec + .discriminator + .as_deref() + .map(|value| format!("_p_{value}")) + .unwrap_or_default(); + PredictedBackend { + name: format!( + "{}_{}_{}_{}ms{cert_suffix}{discriminator}", + spec.scheme, + spec.host, + port, + spec.first_byte_timeout.as_millis() + ), + port, + } +} + +fn predict_cloudflare(spec: &PlatformBackendSpec) -> PredictedBackend { + predict_no_registration(spec) +} + +fn predict_spin(spec: &PlatformBackendSpec) -> PredictedBackend { + predict_no_registration(spec) +} + +fn canonicalize_fastly_transport_timeout_ms(remaining_ms: u32, configured_ms: u32) -> u32 { + if remaining_ms >= configured_ms { + return configured_ms; + } + if remaining_ms >= FASTLY_TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS { + return FASTLY_TRANSPORT_TIMEOUT_COARSE_LADDER_MS + .into_iter() + .rev() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(FASTLY_TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS); + } + let floored = + (remaining_ms / FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS) * FASTLY_TRANSPORT_TIMEOUT_QUANTUM_MS; + if floored > 0 { + return floored; + } + FASTLY_SUB_QUANTUM_LADDER_MS + .into_iter() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + fn spec() -> PlatformBackendSpec { + PlatformBackendSpec { + scheme: "https".to_owned(), + host: "origin.example.com".to_owned(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(1500), + between_bytes_timeout: Duration::from_millis(1500), + discriminator: Some("provider-one".to_owned()), + } + } + + #[test] + fn adapter_prediction_outputs_are_pinned() { + let spec = spec(); + assert_eq!( + BackendNamingPolicy::Fastly + .predict(&spec) + .expect("should predict Fastly backend"), + PredictedBackend { + name: "backend_https_origin_example_com_443_p_provider-one_fb1500_bb1500_51fb8dba14db39e759e2c1c5d204a6eb".to_owned(), + port: 443, + } + ); + assert_eq!( + BackendNamingPolicy::Axum + .predict(&spec) + .expect("should predict Axum backend"), + PredictedBackend { + name: "HTTPS_ORIGIN_EXAMPLE_COM_443_p_PROVIDER_ONE".to_owned(), + port: 443, + } + ); + let no_registration = PredictedBackend { + name: "https_origin.example.com_443_1500ms_p_provider-one".to_owned(), + port: 443, + }; + assert_eq!( + BackendNamingPolicy::Cloudflare + .predict(&spec) + .expect("should predict Cloudflare backend"), + no_registration + ); + assert_eq!( + BackendNamingPolicy::Spin + .predict(&spec) + .expect("should predict Spin backend"), + no_registration + ); + } + + #[test] + fn target_descriptors_pin_all_capabilities_and_policies() { + let cases = [ + (AuctionTargetId::Fastly, true, BackendNamingPolicy::Fastly), + (AuctionTargetId::Axum, true, BackendNamingPolicy::Axum), + ( + AuctionTargetId::Cloudflare, + false, + BackendNamingPolicy::Cloudflare, + ), + (AuctionTargetId::Spin, false, BackendNamingPolicy::Spin), + ]; + for (id, fanout, naming_policy) in cases { + let descriptor = id.descriptor(); + assert_eq!(descriptor.id(), id); + assert_eq!(descriptor.naming_policy(), naming_policy); + assert_eq!( + descriptor + .capabilities() + .supports_concurrent_provider_fanout(), + fanout, + "should declare fanout accurately for {}", + id.adapter_id() + ); + assert!( + !descriptor + .capabilities() + .has_enforceable_total_request_deadline(), + "no current adapter should claim a total request deadline" + ); + assert_eq!(AuctionTargetId::from_adapter_id(id.adapter_id()), Some(id)); + } + assert_eq!(AuctionTargetId::from_adapter_id("FASTLY"), None); + assert_eq!(AuctionTargetId::from_adapter_id("unknown"), None); + } + + #[test] + fn timeout_policies_preserve_logical_transport_distinction() { + assert_eq!( + BackendNamingPolicy::Fastly.canonicalize_transport_timeout_ms(999, 2000), + 750 + ); + assert_eq!( + BackendNamingPolicy::Fastly.canonicalize_transport_timeout_ms(2000, 100), + 100 + ); + for policy in [ + BackendNamingPolicy::Axum, + BackendNamingPolicy::Cloudflare, + BackendNamingPolicy::Spin, + ] { + assert_eq!(policy.canonicalize_transport_timeout_ms(999, 2000), 999); + assert_eq!(policy.canonicalize_transport_timeout_ms(2000, 100), 100); + } + } +} diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index c93cd757d..039df014c 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -290,6 +290,16 @@ pub trait PlatformHttpClient: Send + Sync { true } + /// Whether the adapter enforces a hard total deadline for each request. + /// + /// First-byte and between-byte timers do not qualify: connection setup or a + /// byte-trickling response can still overrun the logical auction budget. The + /// auction collector uses this explicit capability to decide whether an + /// already-completed late response remains eligible. + fn has_enforceable_total_request_deadline(&self) -> bool { + false + } + /// Whether [`send`](Self::send) can preserve upstream response bodies as /// [`Body::Stream`](edgezero_core::body::Body::Stream) when requested via /// [`PlatformHttpRequest::with_stream_response`]. diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 287f1accf..f140a3469 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -32,6 +32,7 @@ use std::time::Duration; +mod backend_naming; mod error; mod http; mod image_optimizer; @@ -41,6 +42,10 @@ pub(crate) mod test_support; mod traits; mod types; +pub use backend_naming::{ + AuctionTargetCapabilities, AuctionTargetDescriptor, AuctionTargetId, BackendNamingError, + BackendNamingPolicy, PredictedBackend, +}; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; pub use http::{ diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 94b42161c..808be5ec6 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, VecDeque}; use std::net::IpAddr; use std::sync::{Arc, Mutex}; +use std::time::Duration; use base64::{Engine as _, engine::general_purpose}; use ed25519_dalek::SigningKey; @@ -132,6 +133,10 @@ impl PlatformSecretStore for HashMapSecretStore { pub(crate) struct NoopBackend; impl PlatformBackend for NoopBackend { + fn naming_policy(&self) -> super::BackendNamingPolicy { + super::BackendNamingPolicy::Axum + } + fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { Err(Report::new(PlatformError::Unsupported)) } @@ -178,6 +183,10 @@ impl PlatformHttpClient for NoopHttpClient { pub(crate) struct StubBackend; impl PlatformBackend for StubBackend { + fn naming_policy(&self) -> super::BackendNamingPolicy { + super::BackendNamingPolicy::Axum + } + fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { Ok("stub-backend".to_owned()) } @@ -213,17 +222,28 @@ struct StubPendingResponse { /// sites. /// Upper bound on the request body bytes captured per `send` call. const MAX_RECORDED_BODY_BYTES: usize = 64 * 1024 * 1024; +type RecordedHeaderBytes = Vec)>>; pub(crate) struct StubHttpClient { calls: Mutex>, responses: Mutex>, // Headers captured per send call, stored as (name, value) string pairs. request_headers: Mutex>>, - // Queued select() errors — each pop makes the next select() return ready: Err. - select_errors: Mutex>, + // Raw header values, including invalid UTF-8 values that cannot be represented + // by `recorded_request_headers`. + request_header_bytes: Mutex, + // Queued select() outcomes; true makes that select return ready: Err. + select_errors: Mutex>, + // Test-only overrides for backend metadata on returned pending handles. + pending_backend_name_overrides: Mutex>>, + // Test-only wall-clock delays applied before each select result is returned. + select_delays: Mutex>, // Reported by supports_concurrent_fanout(); set false to emulate // platforms whose send_async executes eagerly (e.g. Cloudflare Workers). concurrent_fanout: std::sync::atomic::AtomicBool, + // Reported by has_enforceable_total_request_deadline(); set true to emulate + // a future adapter with a hard total request deadline. + enforceable_total_request_deadline: std::sync::atomic::AtomicBool, // Reported by supports_streaming_responses(); set true to emulate Fastly's // streaming response support. streaming_responses_supported: std::sync::atomic::AtomicBool, @@ -248,8 +268,12 @@ impl StubHttpClient { calls: Mutex::new(Vec::new()), responses: Mutex::new(VecDeque::new()), request_headers: Mutex::new(Vec::new()), + request_header_bytes: Mutex::new(Vec::new()), select_errors: Mutex::new(VecDeque::new()), + pending_backend_name_overrides: Mutex::new(VecDeque::new()), + select_delays: Mutex::new(VecDeque::new()), concurrent_fanout: std::sync::atomic::AtomicBool::new(true), + enforceable_total_request_deadline: std::sync::atomic::AtomicBool::new(false), streaming_responses_supported: std::sync::atomic::AtomicBool::new(false), image_optimizer_options: Mutex::new(Vec::new()), cache_bypass_flags: Mutex::new(Vec::new()), @@ -266,6 +290,12 @@ impl StubHttpClient { .store(supported, std::sync::atomic::Ordering::Relaxed); } + /// Make `has_enforceable_total_request_deadline()` report the given value. + pub(crate) fn set_enforceable_total_request_deadline(&self, supported: bool) { + self.enforceable_total_request_deadline + .store(supported, std::sync::atomic::Ordering::Relaxed); + } + /// Make `supports_streaming_responses()` report the given value. pub fn set_streaming_responses_supported(&self, supported: bool) { self.streaming_responses_supported @@ -305,7 +335,36 @@ impl StubHttpClient { self.select_errors .lock() .expect("should lock select_errors") - .push_back(()); + .push_back(true); + } + + /// Make the next `select()` complete successfully before a later queued error. + pub(crate) fn push_select_success(&self) { + self.select_errors + .lock() + .expect("should lock select_errors") + .push_back(false); + } + + /// Override backend metadata on the next pending handle returned by + /// [`Self::send_async`]. `None` removes the metadata entirely. + pub(crate) fn push_pending_backend_name_override(&self, backend_name: Option<&str>) { + self.pending_backend_name_overrides + .lock() + .expect("should lock pending backend name overrides") + .push_back(backend_name.map(str::to_string)); + } + + /// Queue a wall-clock delay before the next [`Self::select`] result. + /// + /// This is test-only timing control for deadline behavior. It deliberately + /// uses a caller-selected, generous contrast with the tested budget rather + /// than relying on scheduler races. + pub(crate) fn push_select_delay(&self, delay: Duration) { + self.select_delays + .lock() + .expect("should lock select_delays") + .push_back(delay); } /// Return backend names recorded across all `send` calls, in order. @@ -323,6 +382,16 @@ impl StubHttpClient { .clone() } + /// Return raw request header values captured per request, in order. + /// + /// Unlike [`Self::recorded_request_headers`], this includes malformed bytes. + pub(crate) fn recorded_request_header_bytes(&self) -> Vec)>> { + self.request_header_bytes + .lock() + .expect("should lock request_header_bytes") + .clone() + } + /// Return Image Optimizer metadata captured per `send` call, in order. pub fn recorded_image_optimizer_options(&self) -> Vec> { self.image_optimizer_options @@ -383,6 +452,11 @@ impl PlatformHttpClient for StubHttpClient { .load(std::sync::atomic::Ordering::Relaxed) } + fn has_enforceable_total_request_deadline(&self) -> bool { + self.enforceable_total_request_deadline + .load(std::sync::atomic::Ordering::Relaxed) + } + fn supports_streaming_responses(&self) -> bool { self.streaming_responses_supported .load(std::sync::atomic::Ordering::Relaxed) @@ -433,6 +507,16 @@ impl PlatformHttpClient for StubHttpClient { .lock() .expect("should lock request_headers") .push(headers); + let header_bytes = request + .request + .headers() + .iter() + .map(|(name, value)| (name.as_str().to_owned(), value.as_bytes().to_vec())) + .collect(); + self.request_header_bytes + .lock() + .expect("should lock request_header_bytes") + .push(header_bytes); // Capture the outgoing request body so tests can assert it is forwarded. // Propagate collection failures instead of recording an empty body, so @@ -505,6 +589,16 @@ impl PlatformHttpClient for StubHttpClient { .lock() .expect("should lock request_headers") .push(headers); + let header_bytes = request + .request + .headers() + .iter() + .map(|(name, value)| (name.as_str().to_owned(), value.as_bytes().to_vec())) + .collect(); + self.request_header_bytes + .lock() + .expect("should lock request_header_bytes") + .push(header_bytes); // Capture the outgoing request body, mirroring `send()`, so tests // exercising the async fan-out path (`request_bids` providers) can @@ -533,7 +627,17 @@ impl PlatformHttpClient for StubHttpClient { status: response.status, body: response.body, }; - Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) + let override_name = self + .pending_backend_name_overrides + .lock() + .expect("should lock pending backend name overrides") + .pop_front(); + let pending = PlatformPendingRequest::new(pending); + Ok(match override_name { + Some(Some(name)) => pending.with_backend_name(name), + Some(None) => pending, + None => pending.with_backend_name(backend_name), + }) } /// Always marks the first pending request in the input as ready (FIFO order). @@ -551,6 +655,15 @@ impl PlatformHttpClient for StubHttpClient { .attach("select called with empty pending_requests list")); } + let delay = self + .select_delays + .lock() + .expect("should lock select_delays") + .pop_front(); + if let Some(delay) = delay { + std::thread::sleep(delay); + } + let ready_platform = pending_requests.remove(0); let stub = ready_platform .downcast::() @@ -577,7 +690,7 @@ impl PlatformHttpClient for StubHttpClient { .lock() .expect("should lock select_errors") .pop_front() - .is_some(); + .unwrap_or(false); if should_error { return Ok(PlatformSelectResult { @@ -700,6 +813,25 @@ pub(crate) fn build_services_with_http_client( build_services_with_secret_and_http_client(NoopSecretStore, http_client) } +/// Build test services that dispatch HTTP requests with an attested client IP. +pub(crate) fn build_services_with_http_client_and_client_ip( + http_client: Arc, + client_ip: IpAddr, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(StubBackend)) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some(client_ip), + ..ClientInfo::default() + }) + .build() +} + pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) @@ -743,12 +875,13 @@ pub(crate) fn build_services_with_backend_and_http_client( } /// Build a [`RuntimeServices`] with a custom secret store, [`StubBackend`], and HTTP client. -pub(crate) fn build_services_with_secret_and_http_client( +pub(crate) fn build_services_with_config_secret_and_http_client( + config_store: impl PlatformConfigStore + 'static, secret_store: impl PlatformSecretStore + 'static, http_client: Arc, ) -> RuntimeServices { RuntimeServices::builder() - .config_store(Arc::new(NoopConfigStore)) + .config_store(Arc::new(config_store)) .secret_store(Arc::new(secret_store)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) @@ -763,6 +896,14 @@ pub(crate) fn build_services_with_secret_and_http_client( .build() } +/// Build test services with a custom secret store and the standard test config store. +pub(crate) fn build_services_with_secret_and_http_client( + secret_store: impl PlatformSecretStore + 'static, + http_client: Arc, +) -> RuntimeServices { + build_services_with_config_secret_and_http_client(NoopConfigStore, secret_store, http_client) +} + #[cfg(test)] mod tests { use crate::platform::DEFAULT_FIRST_BYTE_TIMEOUT; diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index c6af0a307..6c31cd279 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -2,7 +2,7 @@ use std::net::IpAddr; use error_stack::Report; -use super::{GeoInfo, PlatformBackendSpec, PlatformError, StoreId, StoreName}; +use super::{BackendNamingPolicy, GeoInfo, PlatformBackendSpec, PlatformError, StoreId, StoreName}; /// Synchronous, object-safe access to a key-value config store. /// @@ -94,6 +94,9 @@ pub trait PlatformSecretStore: Send + Sync { /// Synchronous, object-safe dynamic backend management. pub trait PlatformBackend: Send + Sync { + /// Return this adapter's pure backend naming and transport timer policy. + fn naming_policy(&self) -> BackendNamingPolicy; + /// Compute the deterministic backend name for the given spec without /// registering anything. /// @@ -125,12 +128,11 @@ pub trait PlatformBackend: Send + Sync { /// connection pooling nor accumulates registrations toward the per-service /// dynamic backend limit. /// - /// The default returns the exact budget-bound value - /// (`remaining_ms.min(configured_ms)`): adapters that neither register nor - /// enforce a backend-name transport timeout gain nothing from rounding and - /// must not shorten bidder deadlines for no benefit. + /// Delegates to the same pure policy used by startup validation so runtime + /// transport timers and predicted names cannot drift. fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { - remaining_ms.min(configured_ms) + self.naming_policy() + .canonicalize_transport_timeout_ms(remaining_ms, configured_ms) } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 774b126ba..853e3d872 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1715,6 +1715,7 @@ fn make_collect_context<'a>( settings, request: placeholder, timeout_ms: 0, + transport_timeout_ms: 0, provider_responses: None, services, } @@ -2762,6 +2763,7 @@ pub async fn handle_publisher_request( settings, request: &req, timeout_ms: auction_timeout_ms, + transport_timeout_ms: auction_timeout_ms, provider_responses: None, services, }; @@ -2778,8 +2780,18 @@ pub async fn handle_publisher_request( DispatchAuctionOutcome::DispatchFailed { request, provider_responses, + fatal_admission_error, + metadata, elapsed_ms, } => { + if let Some(error) = fatal_admission_error { + log::warn!( + "Auction admission failed before publisher dispatch; continuing without bids: {error:?}" + ); + } + if !metadata.is_empty() { + log::info!("Auction dispatch failure metadata: {metadata:?}"); + } emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -3959,6 +3971,7 @@ pub async fn handle_page_bids( settings, request: &req, timeout_ms, + transport_timeout_ms: timeout_ms, provider_responses: None, services, }; @@ -4140,6 +4153,7 @@ mod tests { creative: Some(creative.to_string()), adomain: None, bidder: "seat".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -4459,8 +4473,14 @@ mod tests { .integrations .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) .expect("should enable diagnostics"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let integration_registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let mut request = HttpRequest::builder() .method(Method::GET) .uri("https://publisher.example/article?ts_console=1") @@ -4498,8 +4518,14 @@ mod tests { #[test] fn stream_publisher_body_round_trips_gzip() { let settings = create_test_settings(); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let integration_registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.js\"}"; let compressed = gzip_encode(input); let params = make_stream_params(&settings, "gzip"); @@ -4529,8 +4555,14 @@ mod tests { #[test] fn stream_publisher_body_round_trips_brotli() { let settings = create_test_settings(); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let integration_registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.css\"}"; let compressed = brotli_encode(input); let params = make_stream_params(&settings, "br"); @@ -4632,7 +4664,7 @@ mod tests { const ORIGIN_ETAG: &str = "\"origin-tag\""; const ORIGIN_LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT"; - const UNEXPECTED_304_PROVIDER: &str = "example_navigation_bidder"; + const UNEXPECTED_304_PROVIDER: &str = "example-navigation-bidder"; const UNEXPECTED_304_BACKEND: &str = "example-navigation-bidder-backend"; struct DispatchingTestProvider; @@ -4691,7 +4723,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for DispatchingTestProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { UNEXPECTED_304_PROVIDER } @@ -4772,7 +4804,7 @@ mod tests { fn settings_with_dispatching_provider() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\nproviders = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n\ + "{}\n[auction]\nenabled = true\n\n[auction.providers.{UNEXPECTED_304_PROVIDER}]\nprotocol = \"openrtb-2.6\"\nendpoint = \"https://unexpected.example/openrtb2/auction\"\nrouting = \"all_eligible\"\n\n\ [creative_opportunities]\ngam_network_id = \"12345\"\n", crate_test_settings_str() ); @@ -4899,6 +4931,45 @@ mod tests { .map(|(_, value)| value.as_str()) } + #[tokio::test] + async fn signer_admission_failure_continues_publisher_origin_without_provider_io() { + let mut settings = settings_with_dispatching_provider(); + settings + .request_signing + .as_mut() + .expect("should configure request signing stores") + .enabled = true; + let plan = Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile signed navigation auction"), + ); + let orchestrator = crate::auction::build_orchestrator_with_plan(plan, &settings) + .expect("should build signed plan-backed orchestrator"); + let stub = Arc::new(StubHttpClient::new()); + queue_cacheable_html_response(&stub); + let services = services_with_telemetry( + Arc::clone(&stub) as Arc, + Arc::new(RecordingTelemetrySink::default()), + ); + let slots = [article_slot()]; + + let response = run_with_orchestrator( + &settings, + &services, + &orchestrator, + &slots, + conditional_navigation_request(), + ) + .await; + + assert_eq!(response_head(response).status, StatusCode::OK); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "signer admission failure should skip provider I/O and still fetch the publisher origin" + ); + } + #[tokio::test] async fn eligible_navigation_bypasses_cache_and_returns_non_storable_html() { // Arrange @@ -6132,8 +6203,14 @@ mod tests { #[test] fn tsjs_dynamic_returns_not_found_for_unknown_filename() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let req = build_request( Method::GET, "https://publisher.example/static/tsjs=unknown.js", @@ -6146,8 +6223,14 @@ mod tests { #[test] fn tsjs_dynamic_serves_unified_bundle_for_known_filename() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-unified.min.js", @@ -6164,8 +6247,14 @@ mod tests { .integrations .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) .expect("should enable diagnostics"); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let mut req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-gpt_diagnostics.min.js", @@ -6230,8 +6319,14 @@ mod tests { #[test] fn tsjs_dynamic_serves_prebid_shim_when_enabled() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-prebid.min.js", @@ -6254,13 +6349,18 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-prebid.min.js", @@ -6277,8 +6377,14 @@ mod tests { #[test] fn tsjs_dynamic_returns_not_found_for_arbitrary_module_name() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-evil.min.js", @@ -6332,8 +6438,14 @@ mod tests { use std::io::Write; let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); // Compress CSS containing an origin URL that should be rewritten. // CSS uses the text URL replacer (not lol_html), so inline URLs are rewritten. @@ -6391,8 +6503,14 @@ mod tests { #[test] fn stream_publisher_body_handles_empty_body() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let params = OwnedProcessResponseParams { content_encoding: String::new(), @@ -6429,8 +6547,14 @@ mod tests { #[test] fn stream_publisher_body_rejects_stream_body_in_sync_path() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let params = OwnedProcessResponseParams { content_encoding: String::new(), origin_host: "origin.example.com".to_string(), @@ -6542,8 +6666,14 @@ mod tests { fn stream_publisher_body_async_processes_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { @@ -6595,8 +6725,14 @@ mod tests { fn stream_publisher_body_async_processes_gzip_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { @@ -6651,8 +6787,14 @@ mod tests { fn stream_publisher_body_async_processes_deflate_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { @@ -6707,8 +6849,14 @@ mod tests { fn stream_publisher_body_async_processes_brotli_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { @@ -6763,8 +6911,14 @@ mod tests { fn stream_publisher_body_async_rejects_truncated_brotli_stream() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { @@ -6832,8 +6986,14 @@ mod tests { fn stream_publisher_body_async_rejects_truncated_gzip_stream() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = non_html_stream_params("gzip"); @@ -6869,8 +7029,14 @@ mod tests { fn stream_publisher_body_async_rejects_truncated_deflate_stream() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = non_html_stream_params("deflate"); @@ -6911,8 +7077,14 @@ mod tests { // decoded expansion exceeds it — the decompression-bomb case the // raw-byte cap alone cannot catch. settings.publisher.max_buffered_body_bytes = 1024; - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = non_html_stream_params("gzip"); @@ -6994,8 +7166,14 @@ mod tests { fn stream_publisher_body_async_processes_stream_with_auction_hold() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let state = Arc::new(Mutex::new(None)); @@ -7058,8 +7236,14 @@ mod tests { fn stream_publisher_body_async_auction_hold_decodes_multi_member_gzip_buffered() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let state = Arc::new(Mutex::new(None)); @@ -7125,8 +7309,14 @@ mod tests { fn stream_publisher_body_async_processes_non_html_stream_after_auction_collect() { futures::executor::block_on(async { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { @@ -7179,7 +7369,14 @@ mod tests { fn drain_streaming_finalize_body(content_encoding: &str, body: EdgeBody) -> Vec { let settings = Arc::new(create_test_settings()); let registry = Arc::new( - IntegrationRegistry::new(&settings).expect("should create integration registry"), + IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"), ); let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); let services = noop_services(); @@ -7291,7 +7488,14 @@ mod tests { fn streaming_finalize_response(params: OwnedProcessResponseParams, body: EdgeBody) -> EdgeBody { let settings = Arc::new(create_test_settings()); let registry = Arc::new( - IntegrationRegistry::new(&settings).expect("should create integration registry"), + IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"), ); let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); let services = noop_services(); @@ -7520,7 +7724,14 @@ mod tests { // responses must carry no body and correct framing per status. let settings = Arc::new(create_test_settings()); let registry = Arc::new( - IntegrationRegistry::new(&settings).expect("should create integration registry"), + IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"), ); let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); @@ -7579,8 +7790,14 @@ mod tests { // The buffered finalizer (Axum/Cloudflare/Spin) must correct bodiless // framing identically to the streaming finalizer. let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); @@ -7659,8 +7876,14 @@ mod tests { // terminal abandonment event so the SSP work and quota consumption stay // observable instead of vanishing silently. let settings = Arc::new(create_test_settings()); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); let make_params = || { @@ -7827,7 +8050,14 @@ mod tests { fn publisher_response_streaming_finalize_holds_auction_and_keeps_gzip_tail() { let settings = Arc::new(create_test_settings()); let registry = Arc::new( - IntegrationRegistry::new(&settings).expect("should create integration registry"), + IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"), ); let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); let services = noop_services(); @@ -7913,8 +8143,14 @@ mod tests { #[test] fn stream_publisher_body_treats_mixed_case_html_as_html() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); let bids_script = r#""#; let state = Arc::new(Mutex::new(Some(bids_script.to_string()))); @@ -7966,8 +8202,14 @@ mod tests { #[test] fn stream_publisher_body_surfaces_mid_stream_decode_error() { let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); // Claim gzip encoding but feed non-gzip bytes. The GzDecoder will // error as soon as it tries to read the gzip header. @@ -8056,8 +8298,14 @@ mod tests { ) .expect("should update nextjs config"); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); assert!( registry.has_html_post_processors(), @@ -8131,8 +8379,14 @@ mod tests { }), ) .expect("should update nextjs config"); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create integration registry"); // Small, single-fragment RSC script — placeholder path (not fallback). let html = br#""#; @@ -8247,6 +8501,7 @@ mod tests { creative: None, adomain: None, bidder: bidder.to_string(), + returned_seat: None, width: 300, height: 250, nurl: Some(nurl.to_string()), @@ -9116,6 +9371,7 @@ mod tests { creative: None, adomain: None, bidder: "thetradedesk".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -9171,6 +9427,7 @@ mod tests { creative: None, adomain: None, bidder: "amazon-aps".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -9286,6 +9543,7 @@ mod tests { creative: None, adomain: None, bidder: "amazon-aps".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -9330,6 +9588,7 @@ mod tests { creative: None, adomain: None, bidder: "kargo".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -9662,7 +9921,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for AuctionIdTestProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { AUCTION_ID_TEST_PROVIDER } @@ -9707,6 +9966,7 @@ mod tests { creative: None, adomain: None, bidder: AUCTION_ID_TEST_PROVIDER.to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -9902,7 +10162,8 @@ mod tests { #[tokio::test] async fn page_bids_response_includes_auction_id_only_for_winning_bids() { let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&[AUCTION_ID_TEST_PROVIDER]); settings .integrations .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) @@ -10058,7 +10319,8 @@ mod tests { } let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&[AUCTION_ID_TEST_PROVIDER]); settings .integrations .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) @@ -10614,7 +10876,7 @@ mod tests { /// `[publisher] domain` from [`crate_test_settings_str`]. const CONFIGURED_DOMAIN: &str = "test-publisher.com"; - const CAPTURING_PROVIDER: &str = "request_capturing_provider"; + const CAPTURING_PROVIDER: &str = "request-capturing-provider"; /// Records the [`AuctionRequest`] the orchestrator dispatched, then /// fails its launch so no real transport handle is needed. @@ -10624,7 +10886,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for RequestCapturingProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { CAPTURING_PROVIDER } @@ -10683,7 +10945,7 @@ mod tests { fn settings_with_capturing_provider() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\nproviders = [\"{CAPTURING_PROVIDER}\"]\n\n\ + "{}\n[auction]\nenabled = true\n\n[auction.providers.{CAPTURING_PROVIDER}]\nprotocol = \"openrtb-2.6\"\nendpoint = \"https://capture.example/openrtb2/auction\"\nrouting = \"all_eligible\"\n\n\ [creative_opportunities]\ngam_network_id = \"12345\"\n", crate_test_settings_str() ); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 1021ee9ec..804b197e4 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -98,7 +98,11 @@ impl Default for Publisher { impl Publisher { /// Known placeholder values that must not be used in production. - pub const PROXY_SECRET_PLACEHOLDERS: &[&str] = &["change-me-proxy-secret", "proxy-secret"]; + pub const PROXY_SECRET_PLACEHOLDERS: &[&str] = &[ + "change-me-proxy-secret", + "proxy-secret", + "replace-with-random-proxy-secret", + ]; /// Returns the EC cookie domain, computed as `.{domain}`. /// @@ -168,6 +172,23 @@ pub struct IntegrationSettings { pub trait IntegrationConfig: DeserializeOwned + Validate { fn is_enabled(&self) -> bool; + + /// Validate the public field schema for an explicitly disabled config. + /// + /// Integrations with removed fields override this hook using a disabled-safe + /// typed schema. The default preserves existing support for minimal disabled + /// blocks whose enabled-only required fields are omitted. + /// + /// # Errors + /// + /// Returns a deserialization error when the disabled public field schema is invalid. + fn validate_disabled_schema(raw: &JsonValue) -> Result<(), serde_json::Error> { + match serde_json::from_value::(raw.clone()) { + Ok(_) => Ok(()), + Err(error) if error.to_string().starts_with("missing field ") => Ok(()), + Err(error) => Err(error), + } + } } impl IntegrationSettings { @@ -218,6 +239,11 @@ impl IntegrationSettings { }; if Self::is_explicitly_disabled(raw) { + T::validate_disabled_schema(raw).change_context(TrustedServerError::Configuration { + message: format!( + "Integration '{integration_id}' configuration could not be parsed" + ), + })?; return Ok(None); } @@ -229,6 +255,10 @@ impl IntegrationSettings { }, )?; + if !config.is_enabled() { + return Ok(None); + } + config.validate().map_err(|err| { Report::new(TrustedServerError::Configuration { message: format!( @@ -237,10 +267,6 @@ impl IntegrationSettings { }) })?; - if !config.is_enabled() { - return Ok(None); - } - Ok(Some(config)) } } @@ -459,6 +485,7 @@ impl Ec { "secret_key", "trusted-server", "trusted-server-placeholder-secret", + "replace-with-random-ec-passphrase", ]; /// Default maximum concurrent pull-sync requests. @@ -2448,9 +2475,10 @@ where } // Helper: allow Vec fields to deserialize from either a JSON array or a map of numeric indices. -// This lets env vars like TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS__0=smartadserver work, which the config env source -// represents as an object {"0": "value"} rather than a sequence. Also supports string inputs that are -// JSON arrays or comma-separated values. +// This lets env vars such as +// TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS__0=example-browser work; +// the config env source represents the value as an object rather than a sequence. +// String inputs may also be JSON arrays or comma-separated values. /// Deserializes a `HashMap` from either: /// - A TOML table / JSON object (standard deserialization) /// - A JSON string (e.g. from env var: `'{"Key": "value"}'`) @@ -2586,6 +2614,7 @@ mod tests { use regex::Regex; use serde_json::json; use std::collections::HashSet; + use std::sync::Arc; use crate::auction::build_orchestrator; use crate::integrations::{ @@ -2665,10 +2694,7 @@ mod tests { .integration_config::("prebid") .expect("Prebid config query should succeed") .expect("Prebid config should load from test settings"); - assert_eq!( - prebid_cfg.server_url, - "https://test-prebid.com/openrtb2/auction" - ); + assert_eq!(prebid_cfg.timeout_ms, 1000); assert!( settings .integration_config::("nextjs") @@ -3091,101 +3117,6 @@ origin_host_header_overide = "www.example.com""#, assert!(settings.is_err(), "Should fail when sections are missing"); } - #[test] - fn test_prebid_bidders_override_with_json_env() { - let toml_str = crate_test_settings_str(); - let env_key = format!( - "{}{}INTEGRATIONS{}PREBID{}BIDDERS", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - - // Ensure no external override interferes - let origin_key = format!( - "{}{}PUBLISHER{}ORIGIN_URL", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - temp_env::with_var( - origin_key, - Some("https://origin.test-publisher.com"), - || { - temp_env::with_var(env_key, Some("[\"smartadserver\",\"rubicon\"]"), || { - let res = Settings::from_toml_and_env(&toml_str); - if res.is_err() { - eprintln!("JSON override error: {:?}", res.as_ref().err()); - } - let settings = res.expect("Settings should parse with JSON env override"); - let cfg = settings - .integration_config::("prebid") - .expect("Prebid config query should succeed") - .expect("Prebid config should exist with env override"); - assert_eq!( - cfg.bidders, - vec!["smartadserver".to_string(), "rubicon".to_string()] - ); - }); - }, - ); - } - - #[test] - fn test_prebid_bidders_override_with_indexed_env() { - let toml_str = crate_test_settings_str(); - - let env_key0 = format!( - "{}{}INTEGRATIONS{}PREBID{}BIDDERS{}0", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let env_key1 = format!( - "{}{}INTEGRATIONS{}PREBID{}BIDDERS{}1", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - - // Also ensure origin_url env is a plain string (avoid any external env interference) - let origin_key = format!( - "{}{}PUBLISHER{}ORIGIN_URL", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - temp_env::with_var( - origin_key, - Some("https://origin.test-publisher.com"), - || { - temp_env::with_var(env_key0, Some("smartadserver"), || { - temp_env::with_var(env_key1, Some("openx"), || { - let res = Settings::from_toml_and_env(&toml_str); - if res.is_err() { - eprintln!("Indexed override error: {:?}", res.as_ref().err()); - } - let settings = - res.expect("Settings should parse with indexed env override"); - let cfg = settings - .integration_config::("prebid") - .expect("Prebid config query should succeed") - .expect("Prebid config should exist with indexed env override"); - assert_eq!( - cfg.bidders, - vec!["smartadserver".to_string(), "openx".to_string()] - ); - }); - }); - }, - ); - } - #[test] fn test_handlers_override_with_env() { let toml_str = crate_test_settings_str(); @@ -3735,7 +3666,7 @@ origin_host_header_overide = "www.example.com""#, } #[test] - fn disabled_invalid_integration_skips_validation() { + fn disabled_integration_can_omit_enabled_required_fields_and_skip_semantic_validation() { let mut settings = create_test_settings(); settings .integrations @@ -3743,21 +3674,26 @@ origin_host_header_overide = "www.example.com""#, "gpt", &json!({ "enabled": false, - "script_url": "not a url", }), ) .expect("should insert GPT config"); let config = settings .integration_config::("gpt") - .expect("disabled GPT config should be ignored"); + .expect("minimal disabled GPT config should be ignored"); assert!(config.is_none(), "disabled GPT config should be skipped"); - IntegrationRegistry::new(&settings) - .expect("disabled invalid integration config should not fail registry startup"); + IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("disabled invalid integration config should not fail registry startup"); } #[test] - fn disabled_invalid_default_enabled_prebid_skips_validation() { + fn minimal_disabled_prebid_deserializes_without_enabled_only_validation() { let mut settings = create_test_settings(); settings .integrations @@ -3765,7 +3701,6 @@ origin_host_header_overide = "www.example.com""#, "prebid", &json!({ "enabled": false, - "server_url": "not a url", }), ) .expect("should insert prebid config"); @@ -3774,10 +3709,47 @@ origin_host_header_overide = "www.example.com""#, .integration_config::("prebid") .expect("disabled prebid config should be ignored"); assert!(config.is_none(), "disabled prebid config should be skipped"); - IntegrationRegistry::new(&settings) - .expect("disabled default-enabled prebid config should not fail registry startup"); + IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("disabled default-enabled prebid config should not fail registry startup"); build_orchestrator(&settings) - .expect("disabled default-enabled prebid config should not fail orchestrator startup"); + .expect("minimal disabled prebid config should not fail orchestrator startup"); + } + + #[test] + fn disabled_removed_prebid_and_aps_fields_are_rejected() { + for (integration_id, removed_field) in [("prebid", "server_url"), ("aps", "account_id")] { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + integration_id, + &json!({ + "enabled": false, + (removed_field): "removed-value", + }), + ) + .expect("should insert removed integration config field"); + + let error = match integration_id { + "prebid" => settings + .integration_config::(integration_id) + .expect_err("should reject removed disabled Prebid field"), + "aps" => settings + .integration_config::(integration_id) + .expect_err("should reject removed disabled APS field"), + _ => unreachable!("test integration ID should be known"), + }; + assert!( + format!("{error:?}").contains(removed_field), + "should identify removed field `{removed_field}`: {error:?}" + ); + } } #[test] @@ -3794,7 +3766,13 @@ origin_host_header_overide = "www.example.com""#, ) .expect("should insert GPT config"); - let err = match IntegrationRegistry::new(&settings) { + let err = match IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) { Ok(_) => panic!("enabled invalid integration should fail registry startup"), Err(err) => err, }; @@ -3804,72 +3782,6 @@ origin_host_header_overide = "www.example.com""#, ); } - #[test] - fn disabled_invalid_provider_config_does_not_fail_orchestrator_startup() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "adserver_mock", - &json!({ - "enabled": false, - "endpoint": "not a url", - }), - ) - .expect("should insert adserver mock config"); - - build_orchestrator(&settings).expect("disabled invalid provider config should be ignored"); - } - - #[test] - fn enabled_invalid_provider_config_fails_orchestrator_startup() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "adserver_mock", - &json!({ - "enabled": true, - "endpoint": "not a url", - }), - ) - .expect("should insert adserver mock config"); - - let err = match build_orchestrator(&settings) { - Ok(_) => panic!("enabled invalid provider config should fail startup"), - Err(err) => err, - }; - assert!( - err.to_string().contains("Integration 'adserver_mock'"), - "should identify the invalid provider config" - ); - } - - #[test] - fn empty_prebid_server_url_fails_orchestrator_startup() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "prebid", - &json!({ - "enabled": true, - "server_url": "", - }), - ) - .expect("should insert prebid config"); - - let err = match build_orchestrator(&settings) { - Ok(_) => panic!("empty prebid server_url should fail startup"), - Err(err) => err, - }; - assert!( - err.to_string() - .contains("Integration 'prebid' configuration failed validation"), - "should surface a validation error for prebid.server_url" - ); - } - /// Verifies that `from_toml` does NOT read environment variables. /// The runtime path should only use the pre-built TOML. #[test] @@ -3926,7 +3838,6 @@ origin_host_header_overide = "www.example.com""#, + r#" [auction] enabled = true - providers = [] "#; let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); @@ -3943,7 +3854,6 @@ origin_host_header_overide = "www.example.com""#, + r#" [auction] enabled = true - providers = [] rewrite_creatives = false "#; @@ -3970,7 +3880,6 @@ origin_host_header_overide = "www.example.com""#, + r#" [auction] enabled = true - providers = [] allowed_context_keys = ["permutive_segments", "lockr_ids"] "#; let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); @@ -3986,7 +3895,6 @@ origin_host_header_overide = "www.example.com""#, + r#" [auction] enabled = true - providers = [] allowed_context_keys = [] "#; let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 5f094c0d2..89f73534d 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -23,9 +23,11 @@ pub mod tests { [integrations.prebid] enabled = true - server_url = "https://test-prebid.com/openrtb2/auction" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" + [integrations.prebid.bundle] + adapters = ["exampleBidder"] + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "url"] diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index 17d7c2713..f90bb6dad 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -33,9 +33,7 @@ secret_store_id = "secrets" [integrations.prebid] enabled = false -server_url = "https://prebid.example.com/openrtb2/auction" timeout_ms = 1000 -bidders = [] debug = false client_side_bidders = [] @@ -98,17 +96,38 @@ certificate_check = false [auction] enabled = false -providers = [] timeout_ms = 2000 allowed_context_keys = [] -[integrations.aps] -enabled = true -account_id = "example-aps-account-id" +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.providers.pbs-main.profile_config] +debug = false +test_mode = false +consent_forwarding = "both" + +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = [] + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" endpoint = "https://aps.example.com/e/pb/bid" -timeout_ms = 1000 +routing = "all_eligible" + +[auction.providers.aps-main.profile_config] +account_id = "example-aps-account-id" +debug = false allow_script_creatives = false +[auction.bidders.example-bidder] +provider = "pbs-main" + [integrations.google_tag_manager] enabled = false container_id = "GTM-EXAMPLE" diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 52426027e..213c0a472 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -134,7 +134,8 @@ interface InjectedPrebidConfig { accountId?: string; timeout?: number; debug?: boolean; - bidders?: string[]; + /** Validated browser bidder route codes owned by the server auction plan. */ + serverSideBidders?: string[]; /** Bidders that run client-side via native Prebid.js adapters. */ clientSideBidders?: string[]; /** GAM ad-unit-path suffixes excluded from refresh auctions. */ @@ -156,6 +157,10 @@ export function getInjectedConfig(): InjectedPrebidConfig | undefined { return undefined; } +function injectedServerSideBidderCodes(config = getInjectedConfig()): string[] { + return config?.serverSideBidders ?? []; +} + /** Collect all unique bidder codes from the provided ad units. */ export function collectBidders(adUnits: Array<{ bids?: Array<{ bidder?: string }> }>): string[] { const bidders = new Set(); @@ -629,23 +634,27 @@ function copyParams(params: Record | undefined): Record; } -/** Copy bidder params previously folded into a `trustedServer` bid. */ +/** Copy only plan-owned bidder params previously folded into a `trustedServer` bid. */ function foldedBidderParams( - bid: TrustedServerBid | undefined + bid: TrustedServerBid | undefined, + serverSideBidders: Set ): Record> { const folded = (bid?.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< string, Record >; return Object.fromEntries( - Object.entries(folded).map(([bidder, params]) => [bidder, copyParams(params)]) + Object.entries(folded) + .filter(([bidder]) => serverSideBidders.has(bidder)) + .map(([bidder, params]) => [bidder, copyParams(params)]) ); } /** Capture immutable request-scoped bidder and zone data before the shim mutates an ad unit. */ function capturePublisherAdUnitSnapshot( unit: TrustedServerAdUnit, - clientSideBidders: Set + clientSideBidders: Set, + serverSideBidders: Set ): PublisherAdUnitSnapshot | undefined { if (typeof unit.code !== 'string' || unit.code.length === 0) return undefined; @@ -660,15 +669,19 @@ function capturePublisherAdUnitSnapshot( existingTsBid ??= bid; continue; } - if (clientSideBidders.has(bid.bidder)) { - clientSideBids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + if (!serverSideBidders.has(bid.bidder)) { + if (clientSideBidders.has(bid.bidder)) { + clientSideBids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + } continue; } rawBidderParams[bid.bidder] = copyParams(bid.params); } const bidderParams = - Object.keys(rawBidderParams).length > 0 ? rawBidderParams : foldedBidderParams(existingTsBid); + Object.keys(rawBidderParams).length > 0 + ? rawBidderParams + : foldedBidderParams(existingTsBid, serverSideBidders); const zone = unit.mediaTypes?.banner?.name; return { @@ -733,16 +746,16 @@ function serverSideBidderParamsForRefresh( if (match) { if (!Array.isArray(match.bids)) return {}; - const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); + const serverSideBidders = new Set(injectedServerSideBidderCodes()); const params: Record> = {}; for (const bid of match.bids) { if (!bid?.bidder) continue; if (bid.bidder === ADAPTER_CODE) { - Object.assign(params, foldedBidderParams(bid)); + Object.assign(params, foldedBidderParams(bid, serverSideBidders)); continue; } - if (clientSideBidders.has(bid.bidder)) continue; + if (!serverSideBidders.has(bid.bidder)) continue; params[bid.bidder] = copyParams(bid.params); } @@ -1126,16 +1139,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalRequestBids = pbjs.requestBids.bind(pbjs); - // Bidders that should run client-side via their native Prebid.js adapters. - // Read once from the server-injected config. + // Browser demand ownership is explicit. Only validated auction-plan route + // codes are folded; every other publisher bidder entry remains in Prebid.js. const clientSideBidders = new Set(injected?.clientSideBidders ?? []); + const serverSideBidders = new Set(injectedServerSideBidderCodes(injected)); if (clientSideBidders.size > 0) { log.info('[tsjs-prebid] client-side bidders:', [...clientSideBidders]); } + if (serverSideBidders.size > 0) { + log.info('[tsjs-prebid] server-side bidders:', [...serverSideBidders]); + } // Shim requestBids to inject the trustedServer bidder into every ad unit - // so server-side bids flow through the /auction orchestrator while - // client-side bidders are left untouched. + // so plan-owned server-side bids flow through the /auction orchestrator while + // every unowned bidder is left untouched. pbjs.requestBids = function (requestObj?: Parameters[0]) { log.debug('[tsjs-prebid] requestBids called'); recordUserIdModuleDiagnostics(); @@ -1155,7 +1172,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { if (!syntheticRefreshAdUnits.has(unit)) { - const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); + const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders, serverSideBidders); if (snapshot && unit.code) { storePublisherAdUnitSnapshot(unit.code, snapshot); } @@ -1165,24 +1182,21 @@ export function installPrebidNpm(config?: Partial): typeof pbjs unit.bids = []; } - // Preserve per-bidder params for server-side expansion. - // Skip client-side bidders — they remain as standalone bids and run - // via their native Prebid.js adapters in the browser. + // Preserve params only for bidder codes owned by the validated auction + // plan. Provider IDs, returned seat aliases, APS renderer identity, and + // ordinary browser demand cannot enter the trustedServer envelope. const bidderParams: Record> = {}; for (const bid of unit.bids) { - if (!bid?.bidder || bid.bidder === ADAPTER_CODE) { - continue; - } - if (clientSideBidders.has(bid.bidder)) { + if (!bid?.bidder || !serverSideBidders.has(bid.bidder)) { continue; } bidderParams[bid.bidder] = bid.params ?? {}; } - // Keep only bids that should still execute in the browser. All other - // bidders are routed through the trustedServer adapter. + // Keep every unowned bid in browser demand, including configured native + // adapters and standard entries not claimed by the plan. unit.bids = unit.bids.filter( - (bid) => bid?.bidder === ADAPTER_CODE || clientSideBidders.has(bid?.bidder ?? '') + (bid) => bid?.bidder === ADAPTER_CODE || !serverSideBidders.has(bid?.bidder ?? '') ); // WORKAROUND: Read the zone from mediaTypes.banner.name. This is NOT a @@ -1203,10 +1217,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // by the prior call, so `bidderParams` is now empty. Retain the // params captured on the first call instead of overwriting them with // `{}`, which would drop the publisher's inline PBS params on refresh. - const prevBidderParams = (prevParams[BIDDER_PARAMS_KEY] ?? {}) as Record< - string, - Record - >; + const prevBidderParams = foldedBidderParams(existingTsBid, serverSideBidders); const effectiveBidderParams = Object.keys(bidderParams).length > 0 ? bidderParams : prevBidderParams; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9e9128ce7..74830708a 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -44,7 +44,7 @@ interface InjectedPrebidTestConfig { accountId?: string; timeout?: number; debug?: boolean; - bidders?: string[]; + serverSideBidders?: string[]; clientSideBidders?: string[]; excludedGamAdUnitPathSuffixes?: unknown; } @@ -877,6 +877,12 @@ describe('prebid/installPrebidNpm', () => { }); describe('requestBids shim', () => { + beforeEach(() => { + testWindow.__tsjs_prebid = { + serverSideBidders: ['appnexus', 'rubicon', 'kargo', 'openx'], + }; + }); + it('injects trustedServer bidder into every ad unit', () => { const pbjs = installPrebidNpm(); @@ -911,26 +917,63 @@ describe('prebid/installPrebidNpm', () => { expect(tsCount).toBe(1); }); - it('captures per-bidder params on trustedServer bid', () => { + it('folds only authoritative routes across mixed client, PBS, APS, and standard demand', () => { + testWindow.__tsjs_prebid = { + serverSideBidders: ['pbsRoute', 'standardRoute'], + clientSideBidders: ['exampleBrowser'], + }; const pbjs = installPrebidNpm(); const adUnits = [ { bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, + { bidder: 'exampleBrowser', params: { placement: 'browser' } }, + { bidder: 'pbsRoute', params: { placement: 'pbs' } }, + { bidder: 'aps', params: { slot: 'aps' } }, + { bidder: 'standardRoute', params: { placement: 'standard' } }, + { bidder: 'pbs-provider-id', params: { forbidden: true } }, ], }, ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid).toBeDefined(); - expect(trustedServerBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, + expect(trustedServerBid?.params?.bidderParams).toEqual({ + pbsRoute: { placement: 'pbs' }, + standardRoute: { placement: 'standard' }, }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); + expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual([ + 'exampleBrowser', + 'aps', + 'pbs-provider-id', + 'trustedServer', + ]); + }); + + it('does not let returned bidder aliases or APS renderer aliases affect folding', () => { + testWindow.__tsjs_prebid = { serverSideBidders: ['configuredRoute'] }; + const pbjs = installPrebidNpm(); + const adUnits = [ + { + bids: [ + { bidder: 'configuredRoute', params: { placement: 1 } }, + { bidder: 'alternateReturnedSeat', params: { placement: 2 } }, + { bidder: 'apsRendererAlias', params: { placement: 3 } }, + ], + }, + ]; + + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); + + const trustedServerBid = adUnits[0].bids.find((bid) => bid.bidder === 'trustedServer'); + expect(trustedServerBid?.params?.bidderParams).toEqual({ + configuredRoute: { placement: 1 }, + }); + expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual([ + 'alternateReturnedSeat', + 'apsRendererAlias', + 'trustedServer', + ]); }); it('preserves captured bidder params when requestBids runs twice on the same ad unit', () => { @@ -983,6 +1026,21 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); }); + it('preserves the empty stored-request envelope on initial and repeated requests', () => { + const pbjs = installPrebidNpm(); + const adUnits = [ + { + code: 'stored-slot', + bids: [{ bidder: 'trustedServer', params: { bidderParams: {} } }], + }, + ]; + + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); + + expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + }); + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); @@ -1147,6 +1205,18 @@ describe('prebid/installPrebidNpm with server-injected config', () => { ); }); + it('keeps browser timeout and debug independent from multiple PBS routes', () => { + testWindow.__tsjs_prebid = { + timeout: 1750, + debug: false, + serverSideBidders: ['pbsPrimaryRoute', 'pbsSecondaryRoute'], + }; + + installPrebidNpm(); + + expect(mockSetConfig).toHaveBeenCalledWith({ debug: false, bidderTimeout: 1750 }); + }); + it('explicit config overrides server-injected values', () => { testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; @@ -1174,7 +1244,9 @@ describe('prebid/installRefreshHandler', () => { mockPbjs.setTargetingForGPTAsync = undefined; testWindow.tsjs = undefined; delete testWindow.googletag; - delete testWindow.__tsjs_prebid; + testWindow.__tsjs_prebid = { + serverSideBidders: ['appnexus', 'rubicon', 'kargo', 'openx', 'exampleServer'], + }; }); afterEach(() => { @@ -1346,7 +1418,10 @@ describe('prebid/installRefreshHandler', () => { }); it('includes configured client-side bidders in refresh ad units', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon'], + serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], + }; // Original publisher ad unit carries a client-side rubicon bid. mockPbjs.adUnits = [ { @@ -1467,7 +1542,10 @@ describe('prebid/installRefreshHandler', () => { // publisher's Prebid ad unit is keyed by the inner div_id. The synthetic // refresh code stays the GPT element id (so GPT can match it), while params // and client-side bids are recovered from the injected div_id candidate. - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon'], + serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], + }; mockPbjs.adUnits = [ { code: 'div-ad-x', @@ -2187,7 +2265,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { (window as unknown as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; mockPbjs.setTargetingForGPTAsync = undefined; - delete testWindow.__tsjs_prebid; + testWindow.__tsjs_prebid = { + serverSideBidders: ['exampleServer', 'exampleFallback'], + }; testWindow.tsjs = undefined; delete testWindow.googletag; }); @@ -2554,7 +2634,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['exampleBrowser'], + serverSideBidders: ['exampleServer', 'appnexus'], + }; const runtimeInstance = 'example-runtime-instance'; const code = `example-slot-${runtimeInstance}`; const slot = { @@ -2606,7 +2689,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['exampleBrowser'], + serverSideBidders: ['exampleServer', 'appnexus'], + }; const code = 'example-nested-params-slot'; const slot = { getSlotElementId: () => code, @@ -2779,7 +2865,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['exampleBrowser'], + serverSideBidders: ['exampleServer', 'appnexus'], + }; const code = 'example-live-rich-slot'; const slot = { getSlotElementId: () => code, @@ -2811,6 +2900,51 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ]); }); + it('filters unowned stored bidder params before snapshot, reuse, and refresh recovery', () => { + testWindow.__tsjs_prebid = { serverSideBidders: ['exampleServer'] }; + const code = 'example-stored-envelope-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const adUnits = [ + { + code, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + exampleServer: { placement: 'authoritative' }, + pbsProviderId: { placement: 'provider' }, + returnedSeatAlias: { placement: 'alias' }, + }, + }, + }, + ], + }, + ]; + + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); + expect(adUnits[0].bids[0].params?.bidderParams).toEqual({ + exampleServer: { placement: 'authoritative' }, + }); + + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); + expect(adUnits[0].bids[0].params?.bidderParams).toEqual({ + exampleServer: { placement: 'authoritative' }, + }); + + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params?.bidderParams).toEqual({ + exampleServer: { placement: 'authoritative' }, + }); + }); + it('does not resurrect an older snapshot when the live unit is intentionally empty', () => { const code = 'example-live-empty-slot'; const slot = { @@ -3795,7 +3929,10 @@ describe('prebid/client-side bidders', () => { }); it('excludes client-side bidders from trustedServer bidderParams', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon'], + serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], + }; const pbjs = installPrebidNpm(); @@ -3820,7 +3957,10 @@ describe('prebid/client-side bidders', () => { }); it('preserves client-side bidder bids as standalone entries', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon'], + serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], + }; const pbjs = installPrebidNpm(); @@ -3842,7 +3982,10 @@ describe('prebid/client-side bidders', () => { }); it('handles multiple client-side bidders', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon', 'openx'], + serverSideBidders: ['appnexus', 'exampleServer'], + }; const pbjs = installPrebidNpm(); @@ -3869,8 +4012,8 @@ describe('prebid/client-side bidders', () => { expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); }); - it('behaves normally when no client-side bidders are configured', () => { - // No __tsjs_prebid at all — all bidders go server-side + it('leaves all unowned bidders in browser demand when no routes are configured', () => { + testWindow.__tsjs_prebid = { serverSideBidders: [] }; const pbjs = installPrebidNpm(); const adUnits = [ @@ -3884,14 +4027,19 @@ describe('prebid/client-side bidders', () => { pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); + expect(tsBid.params?.bidderParams).toEqual({}); + expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual([ + 'appnexus', + 'rubicon', + 'trustedServer', + ]); }); it('behaves normally when client-side bidders list is empty', () => { - testWindow.__tsjs_prebid = { clientSideBidders: [] }; + testWindow.__tsjs_prebid = { + clientSideBidders: [], + serverSideBidders: ['appnexus', 'rubicon'], + }; const pbjs = installPrebidNpm(); @@ -3913,7 +4061,10 @@ describe('prebid/client-side bidders', () => { }); it('still injects trustedServer when all bidders are client-side', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon', 'appnexus'], + serverSideBidders: ['openx', 'exampleServer'], + }; const pbjs = installPrebidNpm(); @@ -3940,7 +4091,10 @@ describe('prebid/client-side bidders', () => { adapters: ['rubicon'], bidderCodes: ['rubicon'], }; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon', 'openx'], + serverSideBidders: ['appnexus', 'exampleServer'], + }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -3984,7 +4138,10 @@ describe('prebid/client-side bidders', () => { adapters: ['adf'], bidderCodes: ['adf', 'adform', 'adformOpenRTB'], }; - testWindow.__tsjs_prebid = { clientSideBidders: ['adform'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['adform'], + serverSideBidders: ['appnexus', 'exampleServer'], + }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -4009,7 +4166,10 @@ describe('prebid/client-side bidders', () => { adapters: ['a1Media'], bidderCodes: ['a1media'], }; - testWindow.__tsjs_prebid = { clientSideBidders: ['a1Media'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['a1Media'], + serverSideBidders: ['appnexus', 'exampleServer'], + }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -4031,7 +4191,10 @@ describe('prebid/client-side bidders', () => { it('treats a malformed manifest as unstamped instead of throwing', () => { // The manifest is a plain window global any page script can overwrite. testWindow.__tsjs_prebid_bundle = { adapters: 'rubicon', userIdModules: 42 }; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon'], + serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], + }; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -4048,7 +4211,10 @@ describe('prebid/client-side bidders', () => { it('warns when the external bundle stamped no adapter manifest', () => { delete testWindow.__tsjs_prebid_bundle; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon'], + serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], + }; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -4064,7 +4230,10 @@ describe('prebid/client-side bidders', () => { }); it('does not log errors when all client-side bidders have adapters', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { + clientSideBidders: ['rubicon'], + serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], + }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 6ee858568..6b38d93d2 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -132,7 +132,10 @@ describe('external bundle + served shim evaluated together', () => { // Mirror the server's head-injected state, which always precedes the // bundle script in document order. pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); - pageWindow.__tsjs_prebid = { clientSideBidders: [] }; + pageWindow.__tsjs_prebid = { + clientSideBidders: [], + serverSideBidders: ['appnexus'], + }; pageWindow.eval(bundleCode); @@ -176,7 +179,18 @@ describe('external bundle + served shim evaluated together', () => { { code: 'ad-slot-1', mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + appnexus: { placementId: 1 }, + pbsProviderId: { placementId: 2 }, + returnedSeatAlias: { placementId: 3 }, + }, + }, + }, + ], }, ], timeout: 1000, @@ -203,8 +217,8 @@ describe('external bundle + served shim evaluated together', () => { const payload = JSON.parse(body); const adUnit = payload.adUnits[0]; expect(adUnit.code).toBe('ad-slot-1'); - // The server-side bidder was folded into the trustedServer request - // instead of running client-side. + // Stored trustedServer params retain only authoritative server-side route + // codes; provider IDs and returned aliases cannot reach /auction. const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 9195138a4..8f25d5a27 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -82,38 +82,38 @@ curl -i "https://edge.example.com/_ts/clear-tester" ## First-Party Endpoints -### GET /first-party/ad +### POST /auction -Server-side ad rendering endpoint. Returns complete HTML for a single ad slot. +Browser and programmatic auction endpoint. It accepts the Trusted Server ad-unit +request shape and returns an OpenRTB response with sanitized creatives. -**Query Parameters:** -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `slot` | string | Yes | Ad slot identifier (matches ad unit code) | -| `w` | integer | Yes | Ad width in pixels | -| `h` | integer | Yes | Ad height in pixels | - -**Response:** +**Request Body:** -- **Content-Type:** `text/html; charset=utf-8` -- **Body:** Complete HTML creative with first-party proxying applied +```json +{ + "adUnits": [ + { + "code": "header-banner", + "mediaTypes": { "banner": { "sizes": [[728, 90]] } }, + "bids": [ + { + "bidder": "example-server-bidder", + "params": { "placement": "example-placement" } + } + ] + } + ] +} +``` **Example:** ```bash -curl "https://edge.example.com/first-party/ad?slot=header-banner&w=728&h=90" +curl -X POST https://edge.example.com/auction \ + -H "Content-Type: application/json" \ + -d '{"adUnits":[{"code":"banner","mediaTypes":{"banner":{"sizes":[[300,250]]}}}]}' ``` -**Response Headers:** - -No EC ID response header is emitted. EC identity is maintained with the `ts-ec` cookie. - -**Use Cases:** - -- Server-side ad rendering -- Direct iframe embedding -- First-party ad delivery - --- ## Edge Cookie Endpoints @@ -139,10 +139,10 @@ Returns EC identity plus the authenticated partner's UID and EID for the current "ec": "954d...e0c3.nZ1GxL", "consent": "ok", "degraded": false, - "source_domain": "formally-vital-lion.edgecompute.app", + "source_domain": "ssp.example.com", "uid": "mock-user-123", "eid": { - "source": "formally-vital-lion.edgecompute.app", + "source": "ssp.example.com", "uids": [{ "id": "mock-user-123", "atype": 3 }] }, "cluster_size": 3 @@ -187,63 +187,6 @@ Server-to-server batch sync endpoint for writing EC ID to partner UID mappings. --- -### POST /third-party/ad - -Client-side auction endpoint for TSJS library. - -**Request Body:** - -```json -{ - "adUnits": [ - { - "code": "header-banner", - "mediaTypes": { - "banner": { - "sizes": [ - [728, 90], - [970, 250] - ] - } - } - } - ], - "config": { - "debug": false - } -} -``` - -**Response:** - -```json -{ - "seatbid": [ - { - "bid": [ - { - "impid": "header-banner", - "adm": "...", - "price": 1.5, - "w": 728, - "h": 90 - } - ] - } - ] -} -``` - -**Example:** - -```bash -curl -X POST https://edge.example.com/third-party/ad \ - -H "Content-Type: application/json" \ - -d '{"adUnits":[{"code":"banner","mediaTypes":{"banner":{"sizes":[[300,250]]}}}]}' -``` - ---- - ### GET /first-party/proxy Unified proxy for resources referenced by creatives (images, scripts, CSS, etc.). @@ -597,30 +540,21 @@ All integration modules are built at compile time. At runtime, the server concat ### Prebid Integration -#### GET /first-party/ad +#### POST /auction -See [First-Party Endpoints](#get-first-party-ad) above. +See [First-Party Endpoints](#post-auction) above. -#### POST /third-party/ad +#### GET /integrations/prebid/bundle.js -See [First-Party Endpoints](#post-third-party-ad) above. +Proxies the configured external Prebid bundle through the first-party domain. +The optional `v` query value is the configured SHA-256 cache key. -#### GET /prebid.js (Optional) +#### GET `` (Optional) -Returns empty JavaScript to override Prebid.js when `script_handler` is configured. - -**Configuration:** - -```toml -[integrations.prebid] -script_handler = "/prebid.js" -``` - -**Response:** - -- **Content-Type:** `application/javascript; charset=utf-8` -- **Body:** `// Prebid.js override by Trusted Server` -- **Cache:** `immutable, max-age=31536000` +Each configured Prebid script pattern registers an endpoint that returns empty +JavaScript, preventing the publisher's original Prebid bundle from loading. +The defaults include `/prebid.js`, `/prebid.min.js`, `/prebidjs.js`, and +`/prebidjs.min.js`; set `script_patterns = []` to disable interception. --- @@ -730,13 +664,13 @@ Endpoints under protected paths require HTTP Basic Authentication: [[handlers]] path = "^/_ts/admin" username = "admin" -password = "secure-password" +password = "replace-with-admin-password-32-bytes" ``` **Usage:** ```bash -curl -u admin:secure-password https://edge.example.com/_ts/admin/keys/rotate +curl -u admin:$TRUSTED_SERVER_ADMIN_PASSWORD https://edge.example.com/_ts/admin/keys/rotate ``` **Protected Endpoints:** diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 3b20000ec..7fd9226c3 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -68,7 +68,7 @@ Fermyon Spin adapter (`wasm32-wasip1` component): - Production-capable deployment target for the Spin runtime - Platform services (config store, secret store, KV) backed by Spin component variables and the EdgeZero KV handle - Outbound HTTP via `spin_sdk::http::send` — no configurable per-request timeout (see rustdoc) -- Single auction provider only; multi-provider fan-out requires the Fastly adapter +- Single auction provider only; enabled multi-provider plans fail target validation at startup ```bash # Check (native) @@ -107,6 +107,13 @@ pub trait RequestWrapper { External configuration via `trusted-server.toml` allows deployment-time customization without code changes. +Server-side auctions are configuration-first. `[auction.providers.]` declares +provider instances and `[auction.bidders.]` maps browser-visible bidders to +exactly one provider. Startup compiles these maps into one immutable +`AuctionPlan` shared by orchestration and integration registration. Provider IDs +remain distinct from upstream returned seats and browser delivery bidder codes. +The optional mediator is selected separately by `[auction].mediator`. + ### Consent-Aware Design Data collection operations are subject to available consent signals (TCF v2 format, GPP, GPC). Enforcement follows built-in per-jurisdiction rules, with publisher configuration tuning jurisdiction lists, signal interpretation, and conflict resolution. @@ -160,6 +167,12 @@ Page content and request bodies are processed in-flight and are not persisted. E The workspace has multiple WASM runtimes with runtime-specific SDKs. Use target-matched clippy aliases (`cargo clippy-fastly`, `cargo clippy-spin-native`, etc.) rather than broad `--all-features` workspace clippy — the latter is not a reliable gate across adapters. +Fastly and Axum support concurrent auction provider fan-out. Cloudflare and Spin +currently accept at most one provider in an enabled auction. Every adapter runs +target-aware fan-out and backend-name checks at startup. No current adapter +claims an abortable provider-wide total-request deadline, so configured auction +and provider timeouts are logical budgets rather than hard wall-clock ceilings. + ## Next Steps - Learn about [Configuration](/guide/configuration) diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index e2e9f12fb..48574f240 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -213,22 +213,26 @@ The orchestrator is composed of several modules: | `endpoints.rs` | `crates/trusted-server-core/src/auction/` | HTTP handler for `POST /auction` | | `config.rs` | `crates/trusted-server-core/src/auction/` | Auction configuration types | -### Provider Auto-Discovery +### Configuration-first plan -Providers register themselves at startup via builder functions. The `build_orchestrator()` function in `auction/mod.rs` iterates all registered builders, passes the application settings, and each builder returns zero or more providers depending on whether its config section is present and enabled: +At startup, Trusted Server compiles `[auction.providers]` and +`[auction.bidders]` through one registry into an immutable `AuctionPlan`. +Provider IDs, endpoints, profile defaults, routes, static extensions, and +notification policy are resolved once. The same `Arc` is shared by +the orchestrator and integration registry; request handling does not reinterpret +raw provider configuration. -```rust -// Each integration registers its own builder -fn provider_builders() -> &'static [ProviderBuilder] { - &[ - prebid::register_auction_provider, - aps::register_providers, - adserver_mock::register_providers, - ] -} -``` +The first version registers three OpenRTB 2.6 profiles in Rust: + +- `standard` for the common banner subset and bounded static extensions; +- `prebid-server` for PBS request, response, cache, override, and diagnostics + behavior; and +- `aps` for APS account/SDK fields, response eligibility, and renderer output. -This means you only need to add a config section to `trusted-server.toml` for a provider to be automatically discovered and registered. +Each configured provider is an instance of the generic planned OpenRTB path. +Multiple instances may select the same profile or endpoint and remain distinct +through their provider IDs. The existing `adserver_mock` mediator stays in a +separate static integration path selected by `[auction].mediator`. ## Auction Strategies @@ -241,9 +245,25 @@ When no mediator is set, the orchestrator runs all providers in parallel and sel ```toml [auction] enabled = true -providers = ["prebid", "aps"] -# No mediator — direct price comparison timeout_ms = 2000 + +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" +profile_config = { account_id = "example-aps-account" } + +[auction.bidders.example-server] +provider = "pbs-main" + +# No mediator — direct price comparison ``` **How winner selection works:** @@ -263,9 +283,29 @@ When a `mediator` is configured, provider responses are forwarded to the mediato ```toml [auction] enabled = true -providers = ["prebid", "aps"] -mediator = "adserver_mock" # Enables mediation timeout_ms = 2000 +mediator = "adserver_mock" # Enables mediation + +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" +profile_config = { account_id = "example-aps-account" } + +[auction.bidders.example-server] +provider = "pbs-main" + +[integrations.adserver_mock] +enabled = true +endpoint = "https://mediator.example.com/mediate" +timeout_ms = 500 ``` **How mediation works:** @@ -287,7 +327,7 @@ All demand sources implement the `AuctionProvider` trait: ```rust pub trait AuctionProvider: Send + Sync { - fn provider_name(&self) -> &'static str; + fn provider_name(&self) -> &str; fn request_bids( &self, @@ -336,11 +376,17 @@ Transforms auction requests into OpenRTB 2.x format and sends them to a Prebid S - When `debug` is enabled, PBS debug payload and per-bid status (`bidstatus`) also included ```toml -[integrations.prebid] -enabled = true -server_url = "https://prebid-server.example.com" -timeout_ms = 1000 -bidders = ["appnexus", "rubicon"] +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.providers.pbs-main.profile_config] +debug = false + +[auction.bidders.example-server] +provider = "pbs-main" ``` ### APS Provider @@ -363,10 +409,14 @@ Builds an independent banner OpenRTB request for Amazon Publisher Services. - a minimized typed renderer is preserved instead of creative markup or APS notifications. ```toml -[integrations.aps] -enabled = true -account_id = "example-account" -timeout_ms = 800 +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" + +[auction.providers.aps-main.profile_config] +account_id = "example-aps-account" debug = false allow_script_creatives = false ``` @@ -645,125 +695,130 @@ Each proxied URL includes a `tstoken` HMAC signature for tamper protection. See ## Configuration -### Full Example +### Full example ```toml [auction] enabled = true rewrite_creatives = true -providers = ["prebid", "aps"] -mediator = "adserver_mock" # Remove for parallel_only strategy timeout_ms = 2000 +mediator = "adserver_mock" + +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 900 +routing = "explicit" + +[auction.providers.pbs-main.profile_config] +debug = false +test_mode = false +consent_forwarding = "both" + +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = ["example-seat"] + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" + +[auction.providers.aps-main.profile_config] +account_id = "example-aps-account" +debug = false +allow_script_creatives = false + +[auction.bidders.example-server] +provider = "pbs-main" [integrations.prebid] enabled = true -server_url = "https://prebid-server.example.com" timeout_ms = 1000 -bidders = ["appnexus", "rubicon"] -auto_configure = true debug = false +client_side_bidders = ["example-browser"] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" -[integrations.aps] -enabled = true -account_id = "example-account" -timeout_ms = 800 -debug = false -allow_script_creatives = false +[proxy] +allowed_domains = ["assets.example.com"] [integrations.adserver_mock] enabled = true -endpoint = "https://your-mediator.example.com/adserver/mediate" +endpoint = "https://mediator.example.com/mediate" timeout_ms = 500 -price_floor = 0.50 ``` -### Configuration Reference - -#### `[auction]` - -| Field | Type | Default | Description | -| ------------------- | -------- | ------- | ----------------------------------------------------------------- | -| `enabled` | bool | `false` | Enable the auction system | -| `rewrite_creatives` | bool | `true` | Rewrite sanitized winning-bid `adm` through first-party endpoints | -| `providers` | string[] | `[]` | Ordered list of provider names to call | -| `mediator` | string? | `null` | Provider name to use as mediator (enables `parallel_mediation`) | -| `timeout_ms` | u32 | `2000` | Overall auction timeout in milliseconds | +`[auction.providers]` is a map, not a provider-name list. Each provider ID owns +endpoint/backend correlation and telemetry. `[auction.bidders]` maps each +client-visible bidder ID to one provider. The mediator remains a separately +registered integration selected by `[auction].mediator`. -#### `[integrations.prebid]` +Common provider fields and defaults: -| Field | Type | Default | Description | -| ---------------- | -------- | ----------------- | -------------------------------------------------------------------------------------- | -| `enabled` | bool | `true` | Enable Prebid provider | -| `server_url` | string | — | Prebid Server URL (required) | -| `timeout_ms` | u32 | `1000` | Request timeout | -| `bidders` | string[] | `["mocktioneer"]` | Default bidders when not specified per-slot | -| `auto_configure` | bool | `true` | Auto-remove client-side prebid.js scripts | -| `debug` | bool | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`) | -| `test_mode` | bool | `false` | Set OpenRTB `test: 1` for non-billable test traffic | +| Field | Default | Meaning | +| ---------------- | --------------- | -------------------------------------------------------------- | +| `protocol` | Required | `openrtb-2.6` | +| `profile` | `standard` | Typed OpenRTB behavior | +| `endpoint` | Required | Fixed absolute HTTPS endpoint | +| `timeout_ms` | Profile default | PBS 1000 ms, APS 800 ms, standard inherits auction timeout | +| `routing` | `explicit` | `explicit` or `all_eligible` | +| `profile_config` | `{}` | Profile-owned typed settings | +| `notifications` | No suppression | Common `nurl`/`burl` suppression by all bids or returned seats | -#### `[integrations.aps]` +APS normally uses `all_eligible`, which sends every compatible banner slot but +never another provider's bidder parameters. `explicit` providers receive only +centrally routed or trusted stored-request demand. -| Field | Type | Default | Description | -| ------------------------ | ------ | ----------------------------- | ----------------------------------------------------------------- | -| `enabled` | bool | `false` | Enable APS provider | -| `account_id` | string | — | APS account ID (required; `pub_id` is an alias) | -| `endpoint` | string | Built-in APS OpenRTB endpoint | Optional APS OpenRTB endpoint override | -| `timeout_ms` | u32 | `800` | Request timeout | -| `debug` | bool | `false` | Include the raw APS HTTP exchange in `/auction` provider metadata | -| `inventory_domain` | string | — | Override `site.domain` for APS-authorized inventory | -| `inventory_page_origin` | string | — | HTTPS origin paired with `inventory_domain` for `site.page` | -| `allow_script_creatives` | bool | `false` | Admit script bids before APS candidate reduction | +Provider IDs must match `^[a-z][a-z0-9-]{0,62}$`. Bidder IDs are limited to 128 +UTF-8 bytes and cannot be the exact reserved browser envelope ID +`trustedServer`. Static standard-profile `request_ext` and `imp_ext` objects +are each limited to 16 KiB, eight container levels, and 256 keys at one object +level. Notification seat lists are limited to 128 unique entries of at most 128 +UTF-8 bytes each. -#### `[integrations.adserver_mock]` +### Validation and target capability -| Field | Type | Default | Description | -| ------------- | ------ | ---------------------------------------- | ------------------------- | -| `enabled` | bool | `false` | Enable mediator | -| `endpoint` | string | `http://localhost:6767/adserver/mediate` | Mediator service endpoint | -| `timeout_ms` | u32 | `500` | Request timeout | -| `price_floor` | f64? | `null` | Global price floor CPM | +Target-independent `ts config validate` compiles profiles, defaults, routes, +endpoints, bounds, signing structure, and mediator selection. Every adapter +startup compiles the same plan and then validates backend-name prediction and +fan-out capability. Fastly and Axum allow multi-provider fan-out; Cloudflare and +Spin currently reject enabled auctions with more than one provider. -### Timeout Tuning +This tree does not yet have the EdgeZero callback required to run target-aware +validation before `ts config push --adapter ` performs remote work. +Until that callback lands, startup remains the mandatory target-aware gate. -The orchestrator timeout should exceed the sum of provider timeouts to allow all providers to respond. Providers that exceed their individual timeouts are collected as they finish — the orchestrator doesn't wait indefinitely. - -```toml -[auction] -timeout_ms = 2000 # Overall ceiling +### Timeout behavior -[integrations.prebid] -timeout_ms = 1000 # Prebid Server budget +For each provider, Trusted Server uses the smaller of its resolved timeout and +the remaining auction budget for launch decisions and OpenRTB `tmax`. The +mediator is not launched after the logical auction budget is exhausted. -[integrations.aps] -timeout_ms = 800 # APS budget +No current adapter claims an abortable provider-wide total-request deadline. +Already-launched work may complete after the logical budget, and a completed +late response can remain eligible. Local decision and delivery also finish +after network launch closes, so `timeout_ms` is not a hard wall-clock ceiling +and an auction can exceed it. -[integrations.adserver_mock] -timeout_ms = 500 # Mediator budget (called after providers) -``` +Browser Prebid `timeout_ms` and `debug` stay under `[integrations.prebid]` and +are independent of all server provider values. Server endpoint, timeout, +routes, profile debug/test/overrides/consent, and notification suppression do +not belong to the browser integration. -### Environment Variable Overrides +### Environment variable overrides -The typed `ts config validate`, `ts config diff`, and `ts config push` flows can -override auction values that already exist in the TOML. EdgeZero v0.0.4 does -not create missing leaves, so existing configs must add -`rewrite_creatives = true` before relying on its environment override. +Environment overlays replace leaves that already exist in TOML: ```bash TRUSTED_SERVER__AUCTION__ENABLED=true -TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES=true -TRUSTED_SERVER__AUCTION__PROVIDERS=prebid,aps -TRUSTED_SERVER__AUCTION__MEDIATOR=adserver_mock TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000 -TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://pbs.example.com -TRUSTED_SERVER__INTEGRATIONS__APS__ACCOUNT_ID=example-account -TRUSTED_SERVER__INTEGRATIONS__APS__DEBUG=false +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__ENDPOINT=https://prebid.example.com/openrtb2/auction +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__TIMEOUT_MS=900 +TRUSTED_SERVER__AUCTION__MEDIATOR=adserver_mock ``` -Before rolling back to a binary without `rewrite_creatives`, restore the value -to `true`, push the default-compatible blob, and then roll back. See -[Configuration](/guide/configuration#auction-configuration) for the complete -migration and rollback sequence. - ## Floor Prices Floor prices can be set per-slot in the auction request. The orchestrator enforces floors after winner selection: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c526321c5..4b40a80a4 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -14,17 +14,19 @@ Trusted Server uses a flexible configuration system based on: ### Minimal Configuration -Create `trusted-server.toml` in your project root: +Create `trusted-server.toml` in your project root. Generate both secret values +first with `openssl rand -base64 32`; the placeholders below are intentionally +rejected until replaced. ```toml [publisher] domain = "publisher.com" cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" -proxy_secret = "your-secure-secret-here" +proxy_secret = "replace-with-random-proxy-secret" [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "replace-with-random-ec-passphrase" ``` ### Environment Variable Overrides @@ -37,8 +39,9 @@ read by the deployed application at request time. # Format: TRUSTED_SERVER__SECTION__FIELD export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -export TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +export TRUSTED_SERVER__EC__PASSPHRASE=replace-with-random-ec-passphrase +# Replace the rejected placeholder values in trusted-server.toml, then validate. ts config validate ts config push --adapter fastly ``` @@ -79,15 +82,18 @@ fail and the service will return its startup-error response. ## Example: Production Setup +Generate and substitute every `replace-with-*` value before validation or +deployment. + ```toml [publisher] domain = "publisher.com" cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" -proxy_secret = "change-me-to-secure-value" +proxy_secret = "replace-with-random-proxy-secret" [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "replace-with-random-ec-passphrase" [request_signing] enabled = true @@ -96,10 +102,28 @@ secret_store_id = "01GYYY" [integrations.prebid] enabled = true -server_url = "https://prebid-server.example.com/openrtb2/auction" +client_side_bidders = ["example-browser-bidder"] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" + +[proxy] +allowed_domains = ["assets.example.com"] + +[auction] +enabled = true +timeout_ms = 2000 + +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" timeout_ms = 1200 -bidders = ["kargo", "appnexus", "openx"] -client_side_bidders = ["rubicon"] +routing = "explicit" + +[auction.providers.pbs-main.profile_config] +debug = false + +[auction.bidders.example-server-bidder] +provider = "pbs-main" ``` ## Detailed Reference @@ -143,26 +167,26 @@ TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com **Nested Field**: ```bash -TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid.example/auction +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__ENDPOINT=https://prebid.example.com/openrtb2/auction ``` **Array Field (JSON)**: ```bash -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS='["kargo","rubicon"]' +TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS='["example-browser-a","example-browser-b"]' ``` **Array Field (Indexed)**: ```bash -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS__0=kargo -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS__1=rubicon +TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS__0=example-browser-a +TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS__1=example-browser-b ``` **Array Field (Comma-Separated)**: ```bash -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus +TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS=example-browser-a,example-browser-b ``` ## Publisher Configuration @@ -183,7 +207,7 @@ Core publisher settings for domain, origin, and proxy configuration. > **Note:** EC cookies (`ts-ec`) derive their domain automatically as `.{domain}` and > do not use `cookie_domain`. The `cookie_domain` field is used by other cookie helpers. -**Example**: +**Example** (replace the rejected secret placeholder before validation): ```toml [publisher] @@ -192,7 +216,7 @@ cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" # Optional: connect to origin_url but send this outbound Host header. # origin_host_header_override = "www.publisher.com" -proxy_secret = "change-me-to-secure-random-value" +proxy_secret = "replace-with-random-proxy-secret" ``` **Environment Override**: @@ -417,11 +441,11 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o `source_domain` is the canonical partner key. It matches incoming OpenRTB EID `source` values and is also used as the EC KV `ids` map key. ::: -**Example**: +**Example** (replace the rejected passphrase and token placeholders before validation): ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "replace-with-random-ec-passphrase" ec_store = "ec_identity_store" [[ec.partners]] @@ -592,18 +616,18 @@ Path-based HTTP Basic Authentication. [[handlers]] path = "^/_ts/admin" username = "admin" -password = "secure-password" +password = "replace-with-admin-password-32-bytes" # Multiple handlers [[handlers]] path = "^/secure" username = "user1" -password = "pass1" +password = "replace-with-admin-password" [[handlers]] path = "^/api/private" username = "api-user" -password = "api-pass" +password = "change-me-admin-password" ``` **Environment Override**: @@ -612,7 +636,7 @@ password = "api-pass" # Handler 0 TRUSTED_SERVER__HANDLERS__0__PATH="^/_ts/admin" TRUSTED_SERVER__HANDLERS__0__USERNAME="admin" -TRUSTED_SERVER__HANDLERS__0__PASSWORD="secure-password" +TRUSTED_SERVER__HANDLERS__0__PASSWORD="replace-with-admin-password-32-bytes" # Handler 1 TRUSTED_SERVER__HANDLERS__1__PATH="^/api/private" @@ -1047,78 +1071,82 @@ apply when the integration section exists in `trusted-server.toml`. ### Prebid Integration -**Section**: `[integrations.prebid]` - -| Field | Type | Default | Description | -| -------------------------- | ------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Prebid integration | -| `server_url` | String | Required | Prebid Server endpoint URL | -| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | -| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | -| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | -| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | -| `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | -| `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | -| `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | - -APS is configured exclusively under `[integrations.aps]`. `aps` entries in -`bidders` or `client_side_bidders` are logged and removed case-insensitively so -an upgrade does not prevent Trusted Server from starting. Remove those entries -from operator configuration; this guard prevents APS demand from reaching -Prebid Server or the client-side Prebid bundle. +`[integrations.prebid]` owns browser behavior only. Server endpoint, provider +timeout, routing, profile debug/test controls, consent forwarding, bidder-param +overrides, and notification suppression belong under `[auction]`. + +| Browser field | Type | Default | Description | +| ------------------------------------- | ------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `enabled` | Boolean | `true` | Enable browser bundle injection, interception, and the `trustedServer` adapter | +| `account_id` | String | `None` | Optional account value injected into browser Prebid configuration | +| `timeout_ms` | Integer | `1000` | Browser Prebid.js timeout; independent of every server provider timeout | +| `debug` | Boolean | `false` | Browser Prebid.js debug flag; independent of server profile debug | +| `client_side_bidders` | Array[String] | `[]` | Bidders kept on native browser adapters | +| `excluded_gam_ad_unit_path_suffixes` | Array[String] | `[]` | GAM suffixes excluded from Trusted Server refresh auctions | +| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | Publisher Prebid script paths intercepted by Trusted Server | +| `external_bundle_url` | String | Required when enabled | HTTPS publisher-specific Prebid.js bundle URL | +| `external_bundle_sha256` / `*_sri` | String | `None` | Optional bundle integrity and cache metadata | +| `bundle.adapters` / `user_id_modules` | Array[String] | CLI selection | Inputs used by `ts prebid bundle` | + +Server-side bidder codes are derived from validated `[auction.bidders.*]` +routes and injected into the browser. There is no second server bidder list in +`[integrations.prebid]`. A browser bidder stays client-side only when named in +`client_side_bidders` and its adapter is present in the generated bundle. **Example**: ```toml [integrations.prebid] enabled = true -server_url = "https://prebid-server.example/openrtb2/auction" -timeout_ms = 1200 -bidders = ["kargo", "appnexus", "openx"] +timeout_ms = 1000 debug = false -# test_mode = false +client_side_bidders = ["example-browser"] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +script_patterns = ["/prebid.js", "/prebid.min.js"] -# Bidders that run client-side via native Prebid.js adapters -client_side_bidders = ["rubicon"] +[proxy] +allowed_domains = ["assets.example.com"] -# Customize script interception (optional) -script_patterns = ["/prebid.js", "/prebid.min.js"] +[integrations.prebid.bundle] +adapters = ["example-browser"] -[integrations.prebid.bid_param_overrides.criteo] -networkId = 99999 -pubid = "server-pub" +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" -[integrations.prebid.bid_param_zone_overrides.kargo] -header = { placementId = "_s2sHeaderPlacement" } +[auction.providers.pbs-main.profile_config] +debug = false +test_mode = false +consent_forwarding = "both" +bid_param_overrides = { example-server = { placement = "example-placement" } } -[[integrations.prebid.bid_param_override_rules]] -when.bidder = "kargo" +[[auction.providers.pbs-main.profile_config.bid_param_override_rules]] +when.bidder = "example-server" when.zone = "header" -set = { placementId = "_s2sHeaderPlacement" } +set = { placement = "example-header-placement" } + +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = ["example-seat"] + +[auction.bidders.example-server] +provider = "pbs-main" ``` **Environment Override**: ```bash TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true -TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid.example/auction -TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1200 -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,appnexus,openx -TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":99999,"pubid":"server-pub"}}' -TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_s2sHeaderPlacement"}}}' -TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_s2sHeaderPlacement"}}]' -TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS=rubicon -TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false -TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false -TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG_QUERY_PARAMS=debug=1 -TRUSTED_SERVER__INTEGRATIONS__PREBID__SCRIPT_PATTERNS='["/prebid.js","/prebid.min.js"]' +TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 +TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS=example-browser +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__ENDPOINT=https://prebid.example.com/openrtb2/auction +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__PROFILE_CONFIG='{"debug":false,"test_mode":false,"consent_forwarding":"both"}' ``` +Environment overlays only replace leaves already present in TOML. + **Script Pattern Matching**: The `script_patterns` configuration determines which Prebid scripts are intercepted and replaced with empty JavaScript responses. This prevents client-side Prebid.js from loading when using server-side bidding. @@ -1129,13 +1157,19 @@ The `script_patterns` configuration determines which Prebid scripts are intercep See [Prebid Integration](/guide/integrations/prebid) for full details. -**Bid Param Override Surfaces**: +**Server Bid Param Override Surfaces**: -- `bid_param_overrides`: Static per-bidder shallow-merge overrides. -- `bid_param_zone_overrides`: Per-bidder, per-zone shallow-merge overrides. -- `bid_param_override_rules`: Canonical ordered rules with `when` matchers and `set` objects. +These fields belong under +`[auction.providers..profile_config]` for a `prebid-server` provider: -Compatibility fields are normalized into the same runtime engine as canonical rules. Explicit `bid_param_override_rules` run after compatibility-derived rules, so later canonical rules win on conflicts. +- `bid_param_overrides`: static per-bidder shallow-merge overrides; +- `bid_param_zone_overrides`: per-bidder, per-zone shallow-merge overrides; and +- `bid_param_override_rules`: canonical ordered rules with `when` matchers and + `set` objects. + +Compatibility-shaped fields are normalized into the same profile-local runtime +engine. Explicit rules run after compatibility-derived rules, so later rules +win on conflicts. ### Next.js Integration @@ -1240,79 +1274,159 @@ rewrite_scripts = true ## Auction Configuration -Settings for the auction orchestrator that coordinates multiple bid providers. +`[auction.providers.*]` is the only server-side provider inventory, and +`[auction.bidders.*]` is the only client-visible bidder route map. The optional +`[auction].mediator` remains a separate integration selection; it is not a +provider or bidder route. ### `[auction]` -| Field | Type | Default | Description | -| ------------------- | ------------- | ------------------ | ----------------------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable the auction orchestrator | -| `rewrite_creatives` | Boolean | `true` | Rewrite sanitized winning-bid `adm` through first-party endpoints | -| `providers` | Array[String] | `[]` | Provider names that participate (e.g., `["prebid", "aps"]`) | -| `mediator` | String | Optional | Mediator provider name (runs parallel mediation when set) | -| `timeout_ms` | Integer | `2000` | Auction timeout in milliseconds | -| `creative_store` | String | `"creative_store"` | Deprecated; creatives are now delivered inline | - -Creative markup delivered by `POST /auction` and the publisher SSAT/page-bids -path is always server-sanitized. With `rewrite_creatives = true` (the default), -eligible absolute or protocol-relative resource and click URLs not excluded by -rewrite configuration are converted to signed first-party endpoints. The -`POST /auction` path emits root-relative endpoints and injects the creative TSJS -runtime when a `` exists; the foreign-origin SSAT renderer emits absolute -endpoints and does not inject that bundle. Setting the option to `false` returns -sanitized but not rewritten `adm` from both paths. Accepted external URLs remain -direct and are not host allowlisted by the sanitizer. The setting does not -affect HTML or CSS fetched through `/first-party/proxy`. See -[Creative Processing](/guide/creative-processing#auction-rewrite-control). +| Field | Type | Default | Description | +| ---------------------- | ------- | ------------------ | ----------------------------------------------------------------- | +| `enabled` | Boolean | `false` | Enable the auction orchestrator | +| `rewrite_creatives` | Boolean | `true` | Rewrite sanitized winning-bid `adm` through first-party endpoints | +| `timeout_ms` | Integer | `2000` | Logical auction budget in milliseconds | +| `mediator` | String | `None` | Optional separate `adserver_mock` mediator | +| `creative_store` | String | `"creative_store"` | Deprecated; creatives are delivered inline | +| `allowed_context_keys` | Array | `[]` | Request context keys admitted into the auction | -::: warning Existing configs and rollback -Configs created before `rewrite_creatives` was introduced must first add -`rewrite_creatives = true` under `[auction]`. After adding the leaf, set -`TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES`, run `ts config validate`, and push -the resolved config. - -The default `true` is omitted from stored JSON so older binaries can read the -blob during rollback. An explicit `false` must remain serialized. Before rolling -back to a binary without this field, remove the `false` environment override (or -set the file value to `true`), push the resulting default-compatible blob, and -only then roll back the binary. -::: +### Provider map -**Example**: +Each table name is the provider ID used for configuration, backend correlation, +health, response metadata, and telemetry. Provider IDs must match +`^[a-z][a-z0-9-]{0,62}$`. Multiple instances may select the same profile and +endpoint because the provider ID remains their distinct runtime identity. ```toml [auction] enabled = true rewrite_creatives = true -providers = ["aps", "prebid"] timeout_ms = 2000 +mediator = "adserver_mock" -[integrations.aps] -enabled = true -account_id = "example-account" +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.providers.pbs-main.profile_config] +debug = false +test_mode = false +consent_forwarding = "both" + +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = ["example-seat"] + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" + +[auction.providers.aps-main.profile_config] +account_id = "example-aps-account" debug = false -# Optional pair for deployments hosted away from APS-authorized inventory. -# inventory_domain = "publisher.example" -# inventory_page_origin = "https://www.publisher.example" allow_script_creatives = false -[integrations.prebid] +[auction.bidders.example-server] +provider = "pbs-main" + +[integrations.adserver_mock] enabled = true -server_url = "https://prebid-server.example.com/openrtb2/auction" -``` +endpoint = "https://mediator.example.com/mediate" +timeout_ms = 500 +``` + +| Provider field | Required | Default | Description | +| ---------------- | -------- | --------------- | ------------------------------------------------------------- | +| `protocol` | Yes | None | Must be `openrtb-2.6` | +| `profile` | No | `standard` | `standard`, `prebid-server`, or `aps` | +| `endpoint` | Yes | None | Absolute HTTPS URL with host and no credentials or fragment | +| `timeout_ms` | No | Profile default | Provider logical budget before the remaining-auction cap | +| `routing` | No | `explicit` | `explicit` or `all_eligible` | +| `profile_config` | No | `{}` | Typed object owned by the selected profile | +| `notifications` | No | No suppression | Common `nurl`/`burl` suppression after response normalization | + +Timeout defaults are 1000 ms for `prebid-server`, 800 ms for `aps`, and the +auction timeout for `standard`. An explicit provider timeout overrides the +profile default. Runtime uses `min(provider timeout, auction time remaining)` +for launch decisions and OpenRTB `tmax`. + +`routing = "explicit"` sends only slots carrying a bidder assigned to that +provider (plus trusted stored-request routes). `routing = "all_eligible"` sends +every banner-compatible slot to the provider, regardless of bidder routes. It +does not disclose bidder parameters assigned to another provider. APS commonly +uses `all_eligible` to preserve its whole-inventory participation. + +### Bidder routes and bounds + +Each `[auction.bidders.]` maps one client-visible bidder ID to exactly +one provider. Bidder IDs must be nonempty, no more than 128 UTF-8 bytes, contain +no control characters or surrounding whitespace, and cannot be the reserved +exact ID `trustedServer`. Browser `trustedServer.bidderParams` accepts at most +128 bidder entries; its optional `zone` is at most 256 UTF-8 bytes. + +For the `standard` profile, `profile_config.request_ext` and `imp_ext` must be +JSON objects. Each object is limited to 16 KiB serialized, eight container +levels, and 256 keys at any one object level. Reserved driver, profile, and +signing fields cannot be overwritten. + +Common notification suppression uses exact returned OpenRTB seat values, not +bidder route IDs: -**Environment Override**: +```toml +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = ["example-seat"] +``` + +`suppress_seats` permits at most 128 unique nonempty entries, each at most 128 +UTF-8 bytes and without ASCII control characters. + +### Validation timing and target limits + +`ts config validate` and ordinary deploy validation compile the complete +target-independent plan: profiles and defaults, routes, endpoint ownership, +extension bounds, notifications, signing structure, and mediator selection. +Target-specific checks are deferred to adapter startup. Startup uses the same +compiled plan and additionally validates backend-name prediction/collisions and +provider fan-out capability. + +Fastly and Axum support multiple configured providers. Cloudflare and Spin +currently reject an enabled auction with more than one provider because those +adapters do not support concurrent provider fan-out. Disabled auctions may keep +dormant multi-provider maps without target rejection. + +A target-aware pre-write `ts config push --adapter ` callback is **not +available in this tree** because the required EdgeZero callback is not yet +available. Until it lands, push performs target-independent validation and +adapter startup is the mandatory target-aware gate. Do not treat a successful +push as proof that a Cloudflare or Spin multi-provider plan can start. + +### Deadline behavior + +Configured timeouts are logical budgets, not hard wall-clock guarantees. No +current adapter claims an abortable provider-wide total-request deadline. +Already-launched work may complete after the logical budget and a completed late +response can remain eligible. Once the logical auction budget is exhausted, +Trusted Server starts no additional provider or mediator network work, then +finishes local decision and delivery. An auction can therefore exceed its +configured wall-clock timeout. + +Creative markup is always sanitized. `rewrite_creatives = false` skips only +first-party URL rewriting and creative TSJS injection. See +[Creative Processing](/guide/creative-processing#auction-rewrite-control). + +**Environment overrides** replace map leaves that already exist in TOML: ```bash TRUSTED_SERVER__AUCTION__ENABLED=true -TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES=true -TRUSTED_SERVER__AUCTION__PROVIDERS=aps,prebid -TRUSTED_SERVER__AUCTION__PROVIDERS__0=aps -TRUSTED_SERVER__AUCTION__PROVIDERS__1=prebid -TRUSTED_SERVER__AUCTION__MEDIATOR=adserver_mock TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000 -TRUSTED_SERVER__AUCTION__CREATIVE_STORE=creative_store -TRUSTED_SERVER__INTEGRATIONS__APS__DEBUG=false +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__ENDPOINT=https://prebid.example.com/openrtb2/auction +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__TIMEOUT_MS=900 +TRUSTED_SERVER__AUCTION__MEDIATOR=adserver_mock ``` ## Creative Opportunities Configuration @@ -1483,7 +1597,7 @@ Configuration is validated at startup: **EC Validation**: - `passphrase` ≥ 1 character -- `passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"` — case-insensitive) +- `passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"`, `"trusted-server-placeholder-secret"`, or `"replace-with-random-ec-passphrase"` — case-insensitive) **Handler Validation**: @@ -1509,8 +1623,7 @@ Configuration is validated at startup: **Error Format**: ``` -Configuration error: Integration 'prebid' configuration failed validation: -server_url: must not be empty +Configuration error: provider `pbs-main` endpoint must be an absolute HTTPS URL ``` ## Best Practices @@ -1594,10 +1707,9 @@ trusted-server.dev.toml # Development overrides **"Configuration field '...' is set to a known placeholder value"**: -- `ec.passphrase` cannot be `"secret-key"`, `"secret_key"`, or `"trusted-server"` (case-insensitive) -- `publisher.proxy_secret` cannot be `"change-me-proxy-secret"` (case-insensitive) -- Must be non-empty -- Change to a secure random value (see generation commands above) +- `ec.passphrase`, `publisher.proxy_secret`, handler passwords, and partner API tokens reject known `change-me-*`, `replace-with-*`, and fixture placeholders (case-insensitive) +- Values must be non-empty and satisfy their field-specific minimum lengths +- Replace every placeholder with an independently generated secure random value (see the generation command above) **"Invalid regex"**: diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index a11a352a8..0257d594d 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -12,24 +12,26 @@ This guide covers: ## Prerequisites -- Trusted Server deployed and reachable (example: `https://getpurpose.ai`) +- Trusted Server deployed and reachable (example: `https://trusted-server.example.com`) - Access to update `trusted-server.toml` / deployment configuration - Fastly CLI authenticated (for store verification) - A valid TCF v2 format string (`euconsent-v2`) for consent-required requests ## 1) Required Configuration -Set EC configuration in `trusted-server.toml`: +Generate the passphrase and partner token independently with +`openssl rand -base64 32`, then set EC configuration in `trusted-server.toml`. +The `replace-with-*` values below are intentionally rejected placeholders: ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "replace-with-random-ec-passphrase" ec_store = "ec_identity_store" [[ec.partners]] name = "Mocktioneer SSP" -source_domain = "formally-vital-lion.edgecompute.app" -api_token = "test-batch-sync-key-2026" +source_domain = "ssp.example.com" +api_token = "partner-api-token-32-bytes-minimum" bidstream_enabled = true ``` @@ -46,12 +48,12 @@ Required behavior assumptions: ## 2) Configure Demo Variables ```bash -TS_BASE_URL="https://getpurpose.ai" -MOCK_SSP_URL="https://formally-vital-lion.edgecompute.app" +TS_BASE_URL="https://trusted-server.example.com" +MOCK_SSP_URL="https://ssp.example.com" -PARTNER_SOURCE_DOMAIN="formally-vital-lion.edgecompute.app" +PARTNER_SOURCE_DOMAIN="ssp.example.com" PARTNER_NAME="Mocktioneer SSP" -PARTNER_API_KEY="test-batch-sync-key-2026" +PARTNER_API_KEY="partner-api-token-32-bytes-minimum" # Optional: use a real browser EC if already present EC_ID="<64hex.6chars>" @@ -67,8 +69,8 @@ Partners are configured in `trusted-server.toml` and loaded at startup: ```toml [[ec.partners]] name = "Mocktioneer SSP" -source_domain = "formally-vital-lion.edgecompute.app" -api_token = "test-batch-sync-key-2026" +source_domain = "ssp.example.com" +api_token = "partner-api-token-32-bytes-minimum" bidstream_enabled = true ``` @@ -138,10 +140,10 @@ Expected shape: "ec": "", "consent": "ok", "degraded": false, - "source_domain": "formally-vital-lion.edgecompute.app", + "source_domain": "ssp.example.com", "uid": "mock-user-123", "eid": { - "source": "formally-vital-lion.edgecompute.app", + "source": "ssp.example.com", "uids": [{ "id": "mock-user-123", "atype": 3 }] }, "cluster_size": 12 @@ -174,7 +176,7 @@ echo "" | base64 -d | python3 -m json.tool Expected decoded payload contains: -- `source = formally-vital-lion.edgecompute.app` +- `source = ssp.example.com` - `uids[0].id = ` ## 8) Fastly KV Operational Checks diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index b5348ed9f..3715bad87 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -55,13 +55,15 @@ Missing required field: publisher.domain **Cause:** Required configuration field not provided -**Solution:** Add the missing field to `trusted-server.toml`: +**Solution:** Add the missing field to `trusted-server.toml`. The secret below +is an intentionally rejected placeholder; replace it with `openssl rand -base64 32` +before validation. ```toml [publisher] domain = "your-publisher-domain.com" origin_url = "https://origin.your-publisher-domain.com" -proxy_secret = "change-me-to-random-string" +proxy_secret = "replace-with-random-proxy-secret" ``` **Required Fields:** @@ -78,21 +80,26 @@ proxy_secret = "change-me-to-random-string" **Error Message:** ``` -Invalid URL in integrations.prebid.server_url +provider `pbs-main` endpoint must be an absolute HTTPS URL ``` -**Cause:** Malformed URL in configuration +**Cause:** Malformed auction provider endpoint. -**Solution:** Ensure URLs are well-formed with scheme: +**Solution:** Configure an absolute HTTPS endpoint with a host and no embedded +credentials or fragment: ```toml # ❌ Wrong -[integrations.prebid] -server_url = "prebid-server.example.com" +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "prebid.example.com/openrtb2/auction" # ✅ Correct -[integrations.prebid] -server_url = "https://prebid-server.example.com" +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" ``` --- @@ -114,13 +121,13 @@ Failed to parse environment variable: TRUSTED_SERVER__PUBLISHER__DOMAIN TRUSTED_SERVER__PUBLISHER__DOMAIN="example.com" # For numbers -TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__TIMEOUT_MS=1000 # For booleans TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true -# For arrays (comma-separated) -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS="appnexus,rubicon" +# For browser-side bidder arrays (comma-separated) +TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS="exampleBidder,exampleBrowserBidder" ``` See [Configuration Reference](./configuration.md) for complete patterns. @@ -141,17 +148,17 @@ Failed to generate EC ID: HMAC error **Solution:** -1. Ensure `passphrase` is set in `trusted-server.toml`: +1. Generate a passphrase with `openssl rand -base64 32`, then set it in `trusted-server.toml` (the value below is an intentionally rejected placeholder): ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "replace-with-random-ec-passphrase" ``` -2. Or set via environment variable: +2. Or set the generated value via environment variable; do not use the literal placeholder shown below: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +TRUSTED_SERVER__EC__PASSPHRASE=replace-with-random-ec-passphrase ``` --- @@ -164,21 +171,16 @@ TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret Backend not found: prebid-server ``` -**Cause:** Dynamic backend creation failed or backend not configured +**Cause:** Dynamic backend creation for a configured provider endpoint failed. +Provider backends are derived from `[auction.providers.]`; they are not +manually named static Fastly backends. **Solution:** -For integrations using dynamic backends (Prebid, Testlight): - -- Ensure the integration is enabled -- Verify the URL is accessible from Fastly edge -- Check Fastly service limits (backend count) - -For static backends, configure in Fastly dashboard: - -1. Go to Origins → Hosts -2. Add backend with name matching configuration -3. Redeploy service +- Verify the provider endpoint is canonical HTTPS and reachable from the edge +- Check the provider ID and target-specific backend-name validation error +- Check platform backend-count limits +- Run `ts config validate`, then verify target-aware startup validation on the selected adapter --- @@ -220,13 +222,16 @@ Upstream request timeout after 1000ms **Solution:** -1. Increase timeout in configuration: +1. Increase the affected server provider timeout: ```toml -[integrations.prebid] -timeout_ms = 2000 # Increase from default 1000ms +[auction.providers.pbs-main] +timeout_ms = 2000 ``` +Browser `[integrations.prebid].timeout_ms` is independent and does not control +Prebid Server transport. + 2. Verify upstream service is responsive: ```bash @@ -279,13 +284,15 @@ Prebid Server returned 400: Invalid OpenRTB request **Solution:** -1. Enable debug mode: +1. Enable debug mode on the Prebid Server profile: ```toml -[integrations.prebid] -debug = true +[auction.providers.pbs-main] +profile_config = { debug = true } ``` +`[integrations.prebid].debug` controls browser Prebid.js only. + 2. Check logs for request/response details 3. Verify bidders are supported by your Prebid Server 4. Ensure ad unit format is correct: @@ -630,14 +637,18 @@ cargo install viceroy --version 0.17.0 --locked --force ### Enable Debug Logging -**In configuration:** +Browser Prebid.js debug remains under `[integrations.prebid]`: ```toml [integrations.prebid] debug = true +``` + +For Prebid Server diagnostics, enable debug in that provider's profile: -# Or via environment variable -TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=true +```toml +[auction.providers.pbs-main] +profile_config = { debug = true } ``` **Check Fastly logs:** @@ -654,8 +665,10 @@ fastly log-tail # Start local server fastly compute serve -# Test endpoint -curl http://localhost:7676/first-party/ad?slot=test&w=300&h=250 +# Test the auction endpoint +curl -X POST http://localhost:7676/auction \ + -H "Content-Type: application/json" \ + -d '{"adUnits":[{"code":"test","mediaTypes":{"banner":{"sizes":[[300,250]]}}}]}' ``` --- @@ -663,10 +676,10 @@ curl http://localhost:7676/first-party/ad?slot=test&w=300&h=250 ### Validate Configuration ```bash -# Test configuration load -cargo run --bin trusted-server-adapter-fastly -- --validate-config +# Validate the resolved deployment configuration +ts config validate -# Or check startup logs +# Then check startup logs when exercising the runtime fastly compute serve 2>&1 | grep -i "settings" ``` diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 20faf1995..282662b8a 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -108,11 +108,12 @@ Create it: fastly kv-store create --name ec_identity_store ``` -Configure in `trusted-server.toml`: +Generate a passphrase with `openssl rand -base64 32`, then configure it in +`trusted-server.toml`. The value below is an intentionally rejected placeholder: ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "replace-with-random-ec-passphrase" ec_store = "ec_identity_store" ``` diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index fdd7de0f6..34c0ae8ce 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -423,7 +423,7 @@ Configure proxy behavior in `trusted-server.toml`: domain = "publisher.com" cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" -proxy_secret = "your-secure-random-secret" +proxy_secret = "change-me-proxy-secret" # Rejected placeholder; replace before deploy ``` ### Asset Routes diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9314f983b..e80801aee 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -135,10 +135,17 @@ Review the draft, replace placeholders/secrets, then validate it. Edit `trusted-server.toml` to configure: -- Ad server integrations -- KV store mappings -- EC configuration -- Consent settings (`[gdpr]`) +- browser integrations under `[integrations.*]`; +- server auction providers under map-shaped `[auction.providers.]`; +- server bidder routes under `[auction.bidders.]`; +- KV store mappings; +- EC configuration; and +- consent settings (`[gdpr]`). + +Do not put a Prebid Server URL or server bidder list under +`[integrations.prebid]`, and do not put APS account/endpoint/timeout fields under +`[integrations.aps]`. Those server values belong to auction provider common +fields and `profile_config`. Validate the config before pushing it to platform storage: @@ -146,6 +153,11 @@ Validate the config before pushing it to platform storage: ts config validate ``` +This command performs target-independent plan validation. Each adapter performs +mandatory target-aware fan-out and backend-name validation at startup. The +EdgeZero callback needed for target-aware pre-write push validation is not yet +available in this tree, so startup remains the final target gate. + See [Configuration](/guide/configuration) and [Trusted Server CLI](/guide/cli) for details. ## Deploy to Fastly diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 4346fd7e0..cf7da71f6 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -308,22 +308,33 @@ Prebid applies the same steps outlined above with a few notable patterns: ```toml [integrations.prebid] enabled = true -server_url = "https://prebid.example/openrtb2/auction" timeout_ms = 1200 -bidders = ["equativ", "sampleBidder"] -external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +client_side_bidders = ["example-browser"] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" # external_bundle_sha256 = "..." # external_bundle_sri = "sha384-..." # script_patterns = ["/static/prebid/*"] +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.bidders.example-server] +provider = "pbs-main" + [proxy] -allowed_domains = ["assets.example"] +allowed_domains = ["assets.example.com"] ``` The `proxy.allowed_domains` entry is required for `external_bundle_url` and must cover the bundle host plus any HTTPS redirect targets used by that host. -Tests or scaffolding can inject configs by calling `settings.integrations.insert_config("prebid", &serde_json::json!({...}))`, the same helper that other integrations use. +Browser integration tests can inject `[integrations.prebid]` settings with the +same registry helper as other integrations. Server provider and bidder behavior +must be constructed from the compiled auction plan rather than integration-owned +endpoint or bidder fields. **2. Routes Owned by the Integration** diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md index 1e837e63f..3420de0a5 100644 --- a/docs/guide/integrations-overview.md +++ b/docs/guide/integrations-overview.md @@ -18,7 +18,9 @@ Trusted Server provides built-in integrations with third-party services for firs ### Prebid -**What it does:** Enables server-side header bidding through Prebid Server while maintaining first-party context. +**What it does:** Supplies the browser Prebid.js bundle and `trustedServer` +adapter while auction provider maps independently configure server-side Prebid +Server demand. **Key Features:** @@ -34,18 +36,29 @@ Trusted Server provides built-in integrations with third-party services for firs ```toml [integrations.prebid] enabled = true -server_url = "https://prebid-server.example.com" timeout_ms = 1000 -bidders = ["appnexus", "rubicon"] -auto_configure = true debug = false +client_side_bidders = ["example-browser"] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" + +[proxy] +allowed_domains = ["assets.example.com"] + +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +routing = "explicit" + +[auction.bidders.example-server] +provider = "pbs-main" ``` **Endpoints:** -- `GET /first-party/ad` - Server-side ad rendering -- `POST /third-party/ad` - Client-side auction endpoint -- `GET /prebid.js` - Optional empty script override +- `POST /auction` - Browser and programmatic auction endpoint +- `GET /integrations/prebid/bundle.js` - First-party external bundle proxy +- `GET ` - Configured empty-script interception routes **When to use:** You want to monetize your site with programmatic advertising while maintaining first-party context. @@ -323,9 +336,12 @@ All integrations can be configured via environment variables: ```bash # Pattern: TRUSTED_SERVER__INTEGRATIONS__{INTEGRATION}__{SETTING} -# Prebid -TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL="https://new-server.com" +# Existing Prebid browser-map leaves TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=2000 +TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=true + +# Existing provider-map leaves use the validated provider ID segment +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS_MAIN__ENDPOINT="https://prebid.example.com/openrtb2/auction" # Next.js TRUSTED_SERVER__INTEGRATIONS__NEXTJS__ENABLED=true diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 658f5d5bc..7c1752b7c 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -10,7 +10,7 @@ Trusted Server can request banner bids from Amazon Publisher Services (APS) thro The integration supports: - banner impressions; -- APS OpenRTB requests to the integration's built-in production endpoint; +- APS OpenRTB requests to the provider's configured HTTPS endpoint; - decoded-CPM winner selection with or without a mediator; - direct `/auction` rendering; - client-side `trustedServer` Prebid adapter auctions through GAM; and @@ -25,43 +25,85 @@ The integration does not implement: ## Configuration +APS server ownership is entirely under an auction provider. The optional +`[integrations.aps]` table is only a browser-side toggle; it does not own the +APS account, endpoint, timeout, debug behavior, or script policy. APS renderer +support is registered whenever the compiled auction plan contains an `aps` +profile, even if `[integrations.aps]` is absent or disabled. + ```toml -[integrations.aps] +[auction] enabled = true -account_id = "example-aps-account-id" -timeout_ms = 800 -# Include raw APS request/response data in /auction metadata on test sites only. +timeout_ms = 2000 +mediator = "adserver_mock" + +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "all_eligible" + +[auction.providers.aps-main.profile_config] +account_id = "example-aps-account" debug = false -# Set both when the deployment hostname differs from APS-authorized inventory. -# inventory_domain = "publisher.example" -# inventory_page_origin = "https://www.publisher.example" allow_script_creatives = false +# Configure both only when authorized inventory differs from the deployment host. +# inventory_domain = "inventory.example.com" +# inventory_page_origin = "https://www.inventory.example.com" -[auction] +[integrations.adserver_mock] enabled = true -providers = ["aps", "prebid"] -timeout_ms = 2000 +endpoint = "https://mediator.example.com/mediate" +timeout_ms = 500 ``` -`account_id` is the canonical field. `pub_id` remains a compatibility alias for migration, including integer values, but new configuration should not use it. Supplying both names is an error. - -`debug` defaults to `false`. Enable it only on controlled test sites because it includes the raw APS request and response, including identity, consent, device, page, account, bid, and creative data, in the client-visible `/auction` response. +The common provider `endpoint` is required and must be an absolute HTTPS URL +with a host and no credentials or fragment. The legacy `/e/dtb/bid` path is +rejected. `timeout_ms` belongs beside `endpoint`; when omitted, the `aps` +profile default is 800 ms. Runtime caps it by the remaining auction budget. + +`profile_config.account_id` is required, nonempty, and at most 1024 bytes. It is +the canonical field; integration-owned `account_id`, `pub_id`, endpoint, and +timeout fields are not part of the public schema. `debug` and +`allow_script_creatives` both default to `false`. + +Enable `debug` only on controlled test sites because it includes the raw APS +request and response—including identity, consent, device, page, account, bid, +and creative data—in client-visible `/auction` metadata. + +Set `inventory_domain` and `inventory_page_origin` together only when the public +deployment hostname differs from APS-authorized inventory. The domain becomes +`site.domain`. The HTTPS page origin replaces the current page's scheme and host +while preserving its path; query and fragment are removed. The origin must be +the inventory domain or a subdomain and cannot contain credentials, a port, +path, query, or fragment. + +`routing = "all_eligible"` is the usual APS configuration: every +banner-compatible slot is eligible without a synthetic APS bidder entry. It +does not expose bidder parameters routed to another provider. Use +`routing = "explicit"` only when APS participation should require a central +bidder route: -`allow_script_creatives` defaults to `false`. While disabled, APS script bids are rejected before per-impression reduction, floors, mediation, and winner selection. Enable it only for a controlled cohort after the browser-security checks in [Rollout](#rollout) pass. +```toml +[auction.providers.aps-main] +protocol = "openrtb-2.6" +profile = "aps" +endpoint = "https://aps.example.com/e/pb/bid" +routing = "explicit" -Set `inventory_domain` and `inventory_page_origin` together only when the public deployment hostname differs from the inventory identity authorized by APS. The domain becomes `site.domain`. The HTTPS page origin replaces the current page's scheme and host while preserving its path; query and fragment data are removed before forwarding. The origin must be the inventory domain or one of its subdomains and cannot include credentials, a port, path, query, or fragment. These values come only from operator configuration; Trusted Server never accepts APS inventory identity from the client auction payload. +[auction.providers.aps-main.profile_config] +account_id = "example-aps-account" -APS uses ordinary auction slot IDs and banner formats. Legacy creative-opportunity APS `slot_id` configuration is accepted for compatibility but ignored, and `bidders.aps.slotID` is not required. Remove both during migration. +[auction.bidders.aps] +provider = "aps-main" +``` -The APS provider may also participate through a configured mediator: +The optional mediator stays separate under `[auction].mediator`; never declare +it under `[auction.providers]` or `[auction.bidders]`. -```toml -[auction] -enabled = true -providers = ["aps", "prebid"] -mediator = "adserver_mock" -timeout_ms = 2000 -``` +APS uses ordinary auction slot IDs and banner formats. Legacy creative- +opportunity APS `slot_id` values are ignored, and `bidders.aps.slotID` is not +required. ## OpenRTB request @@ -79,7 +121,9 @@ Raw outbound and inbound payloads are logged only at TRACE level. With debug dis ## Debug mode -Set `debug = true` under `[integrations.aps]` to include the direct APS HTTP exchange in the APS provider summary returned by `POST /auction`: +Set `debug = true` under +`[auction.providers..profile_config]` to include the direct APS HTTP +exchange in that provider's summary returned by `POST /auction`: ```json { @@ -102,7 +146,8 @@ Set `debug = true` under `[integrations.aps]` to include the direct APS HTTP exc } ``` -This follows the Prebid Server `metadata.debug.httpcalls` representation. APS makes one direct HTTP call per auction, so the map uses the provider key `aps` with one entry. Request and captured response bodies are strings, and header values are arrays so repeated headers are preserved. If a non-success response body cannot be read within the existing 2 MiB upstream limit, `responsebody` is omitted rather than reported as an empty body. APS does not add PBS-only `resolvedrequest` or `bidstatus` fields. +This follows the Prebid Server `metadata.debug.httpcalls` representation. APS makes one direct HTTP call per provider per auction, so the map uses the +configured provider ID (for example, `aps-main`) with one entry. Request and captured response bodies are strings, and header values are arrays so repeated headers are preserved. If a non-success response body cannot be read within the existing 2 MiB upstream limit, `responsebody` is omitted rather than reported as an empty body. APS does not add PBS-only `resolvedrequest` or `bidstatus` fields. The debug exchange is emitted for successful responses, `204 No Content`, malformed response bodies, and non-success HTTP statuses. Transport failures and auction timeouts happen before an HTTP response reaches the parser and continue to use the orchestrator's normal error metadata. @@ -199,15 +244,22 @@ If script rendering requires weakening the outer sandbox, leave `allow_script_cr ## Migration from the legacy APS integration -This release is a direct protocol cutover: - -1. Replace the legacy `/e/dtb/bid` endpoint with `/e/pb/bid`. -2. Rename `pub_id` to `account_id`. -3. Remove APS-specific slot ID configuration and remove `aps` from Prebid Server bidder lists. Trusted Server also filters APS from PBS requests for this path. -4. Prepare GAM line items and Universal Creative for `hb_bidder=aps` and the selected APS `hb_adid`. +This release is a direct configuration and protocol cutover: + +1. Move `endpoint` and `timeout_ms` to `[auction.providers.]` and use + `/e/pb/bid`; `/e/dtb/bid` remains rejected. +2. Move `account_id`, `debug`, `allow_script_creatives`, and inventory overrides + to the provider's `profile_config`; `pub_id` is not part of the new schema. +3. Remove APS-specific slot ID configuration and any APS entry from old Prebid + Server bidder lists. Use `routing = "all_eligible"` or an explicit + `[auction.bidders.aps]` route. +4. Prepare GAM line items and Universal Creative for `hb_bidder=aps` and the + selected APS `hb_adid`. 5. Disable publisher-native APS demand for the Trusted Server test cohort. -There is no legacy runtime switch. Roll back by disabling `[integrations.aps]`, restoring native APS for the cohort, or deploying the prior binary. +There is no legacy runtime switch. Roll back by disabling `[auction]` or +removing the APS provider, restoring native APS for the cohort, or deploying +the prior binary. ## Rollout @@ -228,10 +280,12 @@ Use fictional values in source-controlled configuration and fixtures. Supply con - Confirm `account_id` and account eligibility with APS. - Confirm the endpoint is `/e/pb/bid` and uses HTTPS without credentials. - If the deployment hostname differs from APS-authorized inventory, configure both `inventory_domain` and `inventory_page_origin` with the APS-approved identity. -- Ensure `aps` appears in `auction.providers`. +- Ensure an `[auction.providers.]` entry selects `profile = "aps"`. - Check aggregate APS drop reasons for currency, dimensions, render source, URL, tag type, or script-gate rejection. - Confirm the provider timeout fits inside the auction timeout. -- On a controlled test site, set `debug = true` and inspect `ext.orchestrator.provider_details[].metadata.debug.httpcalls.aps` in the `/auction` response. +- On a controlled test site, set profile `debug = true` and inspect + `ext.orchestrator.provider_details[].metadata.debug.httpcalls.` in + the `/auction` response. ### Winner targets but does not render diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 32f2827fb..a01f030ad 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -14,82 +14,124 @@ Prebid is the leading open-source header bidding solution that allows publishers ## Configuration +Prebid configuration has two independent owners: + +- `[integrations.prebid]` owns browser Prebid.js behavior: bundle selection and + injection, browser timeout/debug, account injection, script interception, + client-side bidders, and refresh exclusions. +- `[auction.providers.]`, its `profile_config`, `notifications`, and + `[auction.bidders]` own every Prebid Server request. + ```toml [integrations.prebid] enabled = true -server_url = "https://prebid-server.example.com/openrtb2/auction" -timeout_ms = 1200 -bidders = ["kargo", "appnexus", "openx"] +timeout_ms = 1000 debug = false -# test_mode = false - -# Generated external Prebid bundle served through /integrations/prebid/bundle.js. -external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" -# external_bundle_sha256 = "..." -# external_bundle_sri = "sha384-..." - -# Bidders that run client-side via native Prebid.js adapters instead of -# being routed through the server-side auction. -client_side_bidders = ["rubicon"] - -# Keep matching GAM inventory out of Trusted Server's Prebid refresh auctions. -# GAM still refreshes these slots. -excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] - -# Script interception patterns (optional - defaults shown below) -script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] - -# Required when external_bundle_url is configured. Include the bundle host and -# any HTTPS redirect targets used by that host. -[proxy] -allowed_domains = ["assets.example"] +client_side_bidders = ["example-browser"] +excluded_gam_ad_unit_path_suffixes = ["/example-tracking-only"] +script_patterns = ["/prebid.js", "/prebid.min.js"] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +# external_bundle_sha256 = "" +# external_bundle_sri = "sha384-" -# External bundle generation inputs used by `ts prebid bundle`. [integrations.prebid.bundle] -adapters = ["rubicon"] +adapters = ["example-browser"] user_id_modules = ["sharedIdSystem"] -# Optional static per-bidder param overrides (shallow merge) -[integrations.prebid.bid_param_overrides.criteo] -networkId = 99999 -pubid = "server-pub" +[proxy] +allowed_domains = ["assets.example.com"] -# Optional per-bidder, per-zone param overrides (shallow merge) -[integrations.prebid.bid_param_zone_overrides.kargo] -header = {placementId = "_s2sHeaderPlacement"} -in_content = {placementId = "_s2sContentPlacement"} +[auction] +enabled = true +timeout_ms = 2000 -# Optional canonical ordered override rules -[[integrations.prebid.bid_param_override_rules]] -when.bidder = "kargo" +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 900 +routing = "explicit" + +[auction.providers.pbs-main.profile_config] +debug = false +test_mode = false +debug_query_params = "example-debug=1" +consent_forwarding = "both" +bid_param_overrides = { example-server = { placement = "example-placement" } } +bid_param_zone_overrides = { example-server = { header = { placement = "example-header" } } } + +[[auction.providers.pbs-main.profile_config.bid_param_override_rules]] +when.bidder = "example-server" when.zone = "header" -set = { placementId = "_s2sHeaderPlacement" } +set = { placement = "example-rule-placement" } + +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = ["example-seat"] + +[auction.bidders.example-server] +provider = "pbs-main" ``` -### Configuration Options - -| Field | Type | Default | Description | -| ------------------------------------ | ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Prebid integration | -| `server_url` | String | Required | Prebid Server endpoint URL | -| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | -| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `external_bundle_url` | String | Required when enabled | Absolute HTTPS URL of the generated external Prebid bundle, proxied through `/integrations/prebid/bundle.js`; its host must be listed in `proxy.allowed_domains` | -| `external_bundle_sha256` | String | `None` | Optional 64-character hex SHA-256 used for versioned first-party URLs, immutable cache headers, and `sha256:` ETags | -| `external_bundle_sri` | String | `None` | Optional Subresource Integrity metadata added to the same-origin bundle script tag when configured | -| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | -| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | -| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | -| `debug` | Boolean | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`; surfaces debug metadata in auction responses) | -| `test_mode` | Boolean | `false` | Set the OpenRTB `test: 1` flag so bidders treat the auction as non-billable test traffic. Separate from `debug` to avoid suppressing real demand | -| `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side. See [Client-Side Bidders](#client-side-bidders) | -| `excluded_gam_ad_unit_path_suffixes` | Array[String] | `[]` | Exact, case-sensitive GAM ad-unit-path suffixes excluded from Trusted Server's Prebid refresh auction; matching slots still refresh through GAM | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | -| `bundle.adapters` | Array[String] | Required for `ts prebid bundle` | Prebid.js bidder adapter modules imported into the generated external browser bundle | -| `bundle.user_id_modules` | Array[String] | Generator default preset when omitted | Prebid User ID modules imported into the generated external browser bundle | +### Browser configuration options + +| Field | Default | Ownership and behavior | +| ------------------------------------ | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `enabled` | `true` | Enables browser bundle injection/interception; it does not create a server provider | +| `account_id` | `None` | Optional browser-injected account value | +| `timeout_ms` | `1000` | Browser Prebid.js timeout only | +| `debug` | `false` | Browser Prebid.js debug only | +| `client_side_bidders` | `[]` | Native browser adapters that are not folded into `trustedServer` | +| `excluded_gam_ad_unit_path_suffixes` | `[]` | GAM suffixes omitted from Trusted Server refresh auctions | +| `script_patterns` | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | Publisher Prebid scripts intercepted to prevent duplicate instances | +| `external_bundle_url` | Required when enabled | HTTPS generated bundle URL; host and redirects must be in `proxy.allowed_domains` | +| `external_bundle_sha256` | `None` | Optional content hash used for versioning, cache policy, and ETag | +| `external_bundle_sri` | `None` | Optional SRI metadata | +| `bundle.adapters` | Required for `ts prebid bundle` | Browser bidder adapters compiled into the external bundle | +| `bundle.user_id_modules` | Generator preset | Browser User ID modules compiled into the external bundle | + +### Server provider options + +Common fields are `protocol`, `profile`, required HTTPS `endpoint`, optional +`timeout_ms`, and `routing`. The `prebid-server` timeout defaults to 1000 ms; +an explicit provider value overrides it, and the remaining auction budget caps +runtime `tmax`. + +The typed `profile_config` fields are: + +| Field | Default | Behavior | +| -------------------------- | ------- | --------------------------------------------------- | +| `debug` | `false` | PBS request/response diagnostics | +| `test_mode` | `false` | Top-level OpenRTB `test: 1`; independent of debug | +| `debug_query_params` | `None` | Optional page-URL debug query fragment | +| `bid_param_overrides` | `{}` | Static per-bidder shallow merges | +| `bid_param_zone_overrides` | `{}` | Per-bidder/per-zone shallow merges | +| `bid_param_override_rules` | `[]` | Ordered exact-match rules; later matching rules win | +| `consent_forwarding` | `both` | `openrtb_only`, `cookies_only`, or `both` | + +`notifications.suppress_all` replaces the old global notification switch. +`notifications.suppress_seats` removes `nurl` and `burl` only for exact returned +`seatbid.seat` values. It does not match bidder route IDs. See +[Configuration](/guide/configuration#auction-configuration) for bounds. + +### Browser/server bidder ownership + +Every server-side bidder code comes from `[auction.bidders.]`; the browser +integration has no server bidder list. The validated route keys are injected as +`serverSideBidders`. On initial and refresh auctions, only matching publisher +bids are folded into the `trustedServer.bidderParams` envelope. Configured +`client_side_bidders` and other unowned demand remain native browser bids. Both +paths compete in the same Prebid.js auction. + +The reserved `trustedServer` envelope cannot select a provider or endpoint. Its +nested bidder keys resolve through `[auction.bidders]`, and one envelope accepts +at most 128 bidder entries. The optional `zone` fact is limited to 256 UTF-8 +bytes. Missing, `null`, or empty `bidderParams` invokes Prebid stored-request +routing; malformed envelopes do not. + +Browser `timeout_ms`/`debug` never inherit a server provider timeout or profile +debug value. Enabling the browser integration does not create a server provider, +and a `prebid-server` provider can exist independently from browser injection. ## External Bundle Generation @@ -255,15 +297,15 @@ Use `bid_param_overrides` for static per-bidder param overrides when the same ov **Example**: ```toml -[integrations.prebid.bid_param_overrides.criteo] +[auction.providers.pbs-main.profile_config.bid_param_overrides.example-server] networkId = 99999 -pubid = "server-pub" +pubid = "example-server-pub" ``` **Environment variable**: ```text -TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":99999,"pubid":"server-pub"}}' +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__PROFILE_CONFIG__BID_PARAM_OVERRIDES='{"example-server":{"networkId":99999,"pubid":"example-server-pub"}}' ``` ### Bid Param Zone Overrides @@ -283,10 +325,10 @@ The JS adapter reads the zone from `mediaTypes.banner.name` on each Prebid ad un **Example**: ```toml -[integrations.prebid.bid_param_zone_overrides.kargo] -header = {placementId = "_s2sHeaderPlacement"} -in_content = {placementId = "_s2sContentPlacement"} -fixed_bottom = {placementId = "_s2sBottomPlacement"} +[auction.providers.pbs-main.profile_config.bid_param_zone_overrides.example-server] +header = { placementId = "example-header-placement" } +in_content = { placementId = "example-content-placement" } +fixed_bottom = { placementId = "example-bottom-placement" } ``` If the incoming request for zone `header` has: @@ -306,7 +348,7 @@ For an unrecognized zone (e.g., `sidebar`), the incoming params are left unchang **Environment variable**: ```text -TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_s2sHeaderPlacement"}}}' +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__PROFILE_CONFIG__BID_PARAM_ZONE_OVERRIDES='{"example-server":{"header":{"placementId":"example-header-placement"}}}' ``` ### Bid Param Override Rules @@ -326,16 +368,16 @@ Use `bid_param_override_rules` for the canonical ordered override format. Each r **Example**: ```toml -[[integrations.prebid.bid_param_override_rules]] -when.bidder = "kargo" +[[auction.providers.pbs-main.profile_config.bid_param_override_rules]] +when.bidder = "example-server" when.zone = "header" -set = { placementId = "_s2sHeaderPlacement", keep = "server" } +set = { placementId = "example-header-placement", keep = "example" } ``` **Environment variable**: ```text -TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_s2sHeaderPlacement","keep":"server"}}]' +TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__PROFILE_CONFIG__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"example-server","zone":"header"},"set":{"placementId":"example-header-placement","keep":"example"}}]' ``` ## Refresh Auction GAM-Path Opt-Out @@ -378,25 +420,33 @@ external Prebid adapters or User ID modules. ## Client-Side Bidders -Some Prebid.js bid adapters do not work well through Prebid Server (e.g. Magnite/Rubicon). The `client_side_bidders` config field lets you keep these bidders running natively in the browser while routing all other bidders through the server-side auction. +The `client_side_bidders` config field keeps selected demand on native +Prebid.js adapters while validated `[auction.bidders]` routes identify demand +owned by Trusted Server. ### How it works 1. The server injects the `clientSideBidders` list into the page via `window.__tsjs_prebid`. 2. When `pbjs.requestBids()` is called, the TSJS shim checks each bid against the list. 3. **Client-side bidders** are left as standalone bids — their native Prebid.js adapters handle them in the browser. -4. **All other bidders** are absorbed into the `trustedServer` adapter and routed through the `/auction` orchestrator to Prebid Server. +4. **Bidders present in `[auction.bidders]`** are absorbed into the + `trustedServer` adapter and routed through `/auction` to their configured + provider. Unowned bidders remain native browser demand. 5. Both sets of bids compete in the same Prebid.js auction. ### Configuration ```toml [integrations.prebid] -bidders = ["kargo", "appnexus", "openx"] # server-side via PBS -client_side_bidders = ["rubicon"] # native browser adapters +client_side_bidders = ["example-browser"] + +[auction.bidders.example-server] +provider = "pbs-main" ``` -The two lists are independent — the operator manages both explicitly. If a bidder appears in both lists, a warning is logged at startup (the bidder will run in both paths, which is likely unintended). +Do not route the same bidder through `[auction.bidders]` while also listing it in +`client_side_bidders`; choose one owner. Include every client-side adapter in +the generated external bundle. ### External bundle adapter selection @@ -405,7 +455,7 @@ Client-side bidders need their Prebid.js adapter modules included in the generat ```bash cd crates/trusted-server-js/lib npm run build:prebid-external -- \ - --adapters=rubicon,appnexus,openx \ + --adapters=example-browser \ --user-id-modules=sharedIdSystem,uid2IdSystem \ --out=dist/prebid ``` @@ -514,25 +564,18 @@ In practice, this gives operators both: ## Endpoints -### GET /first-party/ad - -Server-side ad rendering for single ad slot. +### POST /auction -**Query Parameters**: - -- `slot` - Ad unit code -- `w` - Width in pixels -- `h` - Height in pixels - -**Response**: Complete HTML creative with first-party proxying. - -### POST /third-party/ad - -Client-side auction endpoint for TSJS library. +Browser and programmatic auction endpoint used by the Trusted Server Prebid adapter. **Request Body**: Ad units configuration **Response**: OpenRTB bid response with creatives +### GET /integrations/prebid/bundle.js + +First-party proxy route for the configured `external_bundle_url`. An optional +`?v=` query enables content-addressed caching. + ### GET `` (Dynamic) Routes are registered dynamically based on the `script_patterns` configuration. Each pattern creates an endpoint that returns an empty JavaScript file to prevent client-side Prebid.js loading. diff --git a/docs/guide/proxy-signing.md b/docs/guide/proxy-signing.md index 2f34678c3..a4b511115 100644 --- a/docs/guide/proxy-signing.md +++ b/docs/guide/proxy-signing.md @@ -19,7 +19,7 @@ Signatures use HMAC-SHA256 with the publisher's `proxy_secret`: ```toml [publisher] -proxy_secret = "your-secret-key-here" # Must be secure random string +proxy_secret = "change-me-proxy-secret" # Rejected placeholder; replace with a secure random string ``` ## Signature Validation diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 365b3b9c7..d5bdbe3b5 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -40,9 +40,7 @@ secret_store_id = "secrets" [integrations.prebid] enabled = false -server_url = "https://prebid.example.com/openrtb2/auction" timeout_ms = 1000 -bidders = [] debug = false client_side_bidders = [] # Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions. @@ -117,27 +115,50 @@ enabled = false # allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] [auction] +# Keep disabled until provider endpoints, routes, and profile values below are +# replaced with deployment-specific settings. enabled = false # Defaults to true. Keep this leaf present when using the EdgeZero v0.0.4 # environment override. Set false to return sanitized but unre-written winning-bid # adm, skipping proxy/click URL conversion and creative TSJS injection. # Sanitization is always applied. Restore and push true before an older-binary rollback. rewrite_creatives = true -providers = [] timeout_ms = 2000 allowed_context_keys = [] -[integrations.aps] -enabled = false -account_id = "example-aps-account-id" -timeout_ms = 1000 -# Include raw APS request/response data in /auction metadata on test sites only. +# Example server-side provider declarations. Remove or customize before enabling auctions. +[auction.providers.pbs-main] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" +# `prebid-server` defaults to 1000 ms when omitted. +routing = "explicit" + +[auction.providers.pbs-main.profile_config] debug = false -# Set both when the deployment hostname differs from APS-authorized inventory. -# inventory_domain = "publisher.example" -# inventory_page_origin = "https://www.publisher.example" -# Script creatives require separate security validation before opt-in. -allow_script_creatives = false +test_mode = false +consent_forwarding = "both" + +[auction.providers.pbs-main.notifications] +suppress_all = false +suppress_seats = [] + +[auction.bidders.example-bidder] +provider = "pbs-main" + +# APS server behavior is also provider/profile-owned. Use routing = "all_eligible" +# when every banner-compatible slot should be eligible. The APS profile timeout +# defaults to 800 ms; debug and script creatives default to false. +# [auction.providers.aps-main] +# protocol = "openrtb-2.6" +# profile = "aps" +# endpoint = "https://aps.example.com/e/pb/bid" +# routing = "all_eligible" +# +# [auction.providers.aps-main.profile_config] +# account_id = "example-aps-account-id" +# debug = false +# allow_script_creatives = false [integrations.google_tag_manager] enabled = false