diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7a145e02..97402e6f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,7 @@ jobs: test-rust: name: cargo test runs-on: ubuntu-latest + timeout-minutes: 45 steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c1aee9b..083d6e746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** Auction providers and bidder routes now use the configuration-first `[auction.providers.]` and `[auction.bidders.]` maps. The removed `[auction].providers = [...]` list and removed server fields under `[integrations.prebid]` and `[integrations.aps]` are rejected even when those integrations are disabled, and `ts config push` rejects the old shape before publication. Move PBS `server_url` to provider `endpoint`, server timeout to provider `timeout_ms`, request controls and bidder-parameter overrides to the `prebid-server` `profile_config`, notification suppression to `notifications`, and each former server bidder to an `[auction.bidders.]` route. Move APS endpoint, timeout, account, inventory, debug, and creative controls to an `aps` provider and its `profile_config`. Browser Prebid settings remain under `[integrations.prebid]`; values such as timeout and debug that previously affected both browser and server behavior must now be configured for each owner. Provider endpoints must be absolute HTTPS URLs. Only bidder codes present in `[auction.bidders]` are folded into Trusted Server requests; unlisted publisher bids remain native browser demand. Provider response names now use the configured provider ID, such as `pbs-main`, instead of the legacy literal `prebid`; audit consumers that match `AuctionResponse.provider`. This schema has no mixed-version-safe deployment order: old binaries reject the maps and new binaries reject the retired fields, so activate the new binary and config blob together. Rollbacks must restore an old-schema blob together with the old binary. - **Breaking** — Admin Basic-auth coverage now includes `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}`, and `GET /_ts/admin/eids`. Existing configurations whose `[[handlers]]` patterns protect only the key-management endpoints now fail startup; broaden coverage before deploying, preferably with a namespace-boundary pattern such as `^/_ts/admin(?:/|$)`. Coverage of the dynamic `/_ts/admin/ec/{id}` route is no longer inferred from ID-shaped samples: the router accepts any segment after `/_ts/admin/ec/` and Basic Auth runs on the raw path before routing, so patterns anchored to the EC ID grammar (for example `^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$`) are rejected in favor of a prefix-level matcher. Placeholder and well-known weak handler passwords (`changeme`, `password`, `admin`, `replace-with-…`) now fail startup on every handler rather than only on handlers inferred to cover an admin endpoint, because first-match-wins handler selection lets a narrow handler shadow the admin namespace. +- Prebid Server provider endpoints now normalize origin-only legacy `server_url` values to `/openrtb2/auction`. Query parameters are preserved, the canonical path loses a trailing slash, and configured non-root custom paths remain exact. - Publisher HTML uses the browser-only `Cache-Control: private, max-age=60` policy for successful GET document responses and their `304 Not Modified` revalidations when server-side ad templates are structurally inactive, while preserving origin `private`/`no-store` policies and request-scoped bot, prefetch, or consent-denied responses. The `private` directive prevents shared caches that use `Cache-Control` from storing the document. Cookie-bearing responses using the generated inactive policy are finalized as `private, max-age=0`; CDN-specific cache headers remain unchanged and continue to control supporting CDNs independently. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers; an absent configuration, an unmatched slot, or a disabled auction also make the stack structurally inactive. An explicit `enabled = false` is not compatible with older binaries: restore the default, re-push and finalize the config before rolling back. - **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape. - **Breaking** — All auction paths now forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the existing Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters. diff --git a/Cargo.lock b/Cargo.lock index e29380b77..d154d8d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5374,7 +5374,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..c50d10a67 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,22 +1,57 @@ -# Testing the Auction Orchestration System +# Testing auction orchestration -## Quick Test Summary +## Start the local server -The auction orchestration system has been integrated into the existing Prebid endpoints. You can test it right away using the Fastly local server! - -## How to Test - -### 1. Start the Local Server +Configure at least one reachable provider in `trusted-server.toml`, then start the +Fastly development server: ```bash fastly compute serve ``` -### 2. Test with Existing Endpoint +Provider endpoints must use HTTPS. Fastly and Viceroy also need a backend that +matches the provider host and TLS settings. For a deterministic local bidder, +use `scripts/template-cache-local-test.sh`, which creates a temporary CA and +registers the matching backend. + +## Example configuration + +```toml +[auction] +enabled = true +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" -The `/auction` endpoint now uses the orchestrator when `auction.enabled = true` in config. +[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 = "https://mediator.example.com/mediate" +timeout_ms = 500 +``` + +Replace the example endpoints and profile values before running the server. +Omit `mediator` to test local highest-bid selection without mediation. + +## Send a routed request + +The PBS provider uses explicit routing, so the request must include params for a +bidder listed in `[auction.bidders]`: -**Test Request:** ```bash curl -X POST http://localhost:7676/auction \ -H "Content-Type: application/json" \ @@ -28,7 +63,15 @@ curl -X POST http://localhost:7676/auction \ "banner": { "sizes": [[728, 90], [970, 250]] } - } + }, + "bids": [ + { + "bidder": "example-server", + "params": { + "placement": "example-header-placement" + } + } + ] }, { "code": "sidebar", @@ -42,144 +85,70 @@ curl -X POST http://localhost:7676/auction \ }' ``` -### 3. What You'll See - -**With Orchestrator Enabled** (`auction.enabled = true`): -- Logs showing: `"Using auction orchestrator"` -- Parallel execution of APS OpenRTB and Prebid Server -- Optional mock-adserver mediation selecting winning bids -- Final response with winning creatives +The first impression routes to `pbs-main` and `aps-main`. The second routes only +to `aps-main` because APS uses `all_eligible` and PBS uses `explicit`. -**With Orchestrator Disabled** (`auction.enabled = false`): -- Logs showing: `"Using legacy Prebid flow"` -- Direct Prebid Server call (backward compatible) +## Check current logs -##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 +Startup logs report plan-backed construction and the provider count: -# 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 - -[integrations.adserver_mock] -enabled = true -endpoint = "http://localhost:6767/adserver/mediate" -timeout_ms = 500 +```text +Building plan-backed auction orchestrator +Auction orchestrator built with 2 bidder providers ``` -## Test Scenarios +A launched request logs the configured provider ID, predicted backend, and +budget. Collection logs the pending and immediate response counts: -### Scenario 1: Parallel + Mediation (Default) -**Config:** -```toml -[auction] -enabled = true -providers = ["prebid", "aps"] -mediator = "adserver_mock" # Mediator configured = parallel mediation strategy +```text +Dispatching bid request to 'pbs-main' (backend: ..., budget: ...ms) +Dispatching bid request to 'aps-main' (backend: ..., budget: ...ms) +Dispatched 2 SSP request(s) with 0 immediate response(s) (timeout: ...ms) ``` -**Expected Flow:** -1. Prebid queries its configured bidders through Prebid Server -2. APS sends an OpenRTB request for eligible banner impressions -3. AdServer Mock mediates the provider responses -4. The winning creative or typed APS renderer is returned +Exact backend names and budgets depend on the adapter and remaining auction +deadline. Provider failures are isolated and appear in response metadata under +the configured provider ID. -### Scenario 2: Parallel Only (No Mediation) -**Config:** -```toml -[auction] -enabled = true -providers = ["prebid", "aps"] -# No mediator = parallel only strategy -``` +## Disabled auction -**Expected Flow:** -1. Prebid and APS run in parallel -2. Highest bid wins automatically -3. No mediation +Set: -### Scenario 3: Legacy Mode (Backward Compatible) -**Config:** ```toml [auction] enabled = false ``` -**Expected Flow:** -- Original Prebid-only behavior -- No orchestration overhead - -## Debugging +`POST /auction` returns an immediate no-bid response, emits an +`auction_disabled` skipped telemetry event, and performs no provider or mediator +work. The request log is: -### Check Logs -The orchestrator logs extensively: +```text +/auction: auction is disabled; returning no-bid response ``` -INFO: Using auction orchestrator -INFO: Running auction with strategy: parallel_mediation -INFO: Running 2 bidders in parallel -INFO: Requesting bids from: prebid -INFO: Prebid returned 2 bids (time: 120ms) -INFO: Requesting bids from: aps -INFO: APS requests bids for 2 impressions -INFO: APS returns 2 accepted bids in 80ms -INFO: GAM mediation: slot 'header-banner' won by 'aps' at $2.50 CPM -``` - -### Verify Provider Registration -Look for these log messages on startup: -``` -INFO: Registering auction provider: prebid -INFO: Registering auction provider: aps -INFO: Registering auction provider: adserver_mock -``` - -### Common Issues - -**Issue:** `"Provider 'aps' not registered"` -**Fix:** Make sure `[integrations.aps]` is configured in `trusted-server.toml` - -**Issue:** `"No providers configured"` -**Fix:** Make sure `providers = ["prebid", "aps"]` is set in `[auction]` -**Issue:** Tests fail with WASM errors -**Explanation:** Async tests don't work in WASM test environment. Integration tests via HTTP work fine! +## Automated checks -## Next Steps +Use the repository aliases instead of bare `cargo test --workspace`: -1. **Verify Prebid Server demand** - Confirm configured bidders return expected test bids -2. **Verify APS eligibility** - Confirm the test account, inventory identity, and `/e/pb/bid` endpoint are authorized -3. **Exercise renderer security** - Run the APS browser integration suite for iframe and script creatives -4. **Add metrics** - Track bid rates, win rates, latency, and aggregate drop reasons per provider +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` -## Provider Behavior +For browser integration tests: -### APS (Amazon) -- Sends real OpenRTB requests for eligible banner slots -- Safely drops malformed, unsupported, or unrenderable bids and reports aggregate reasons -- Reduces multiple APS candidates to one winner per impression -- Returns typed renderer descriptors rather than exposing `adm` outside the sandbox -- Automated tests intercept upstream traffic and use fictional response fixtures +```bash +cd crates/trusted-server-js/lib +npx vitest run +``` -### AdServer Mock -- Acts as mediator by calling mocktioneer's mediation endpoint -- Selects winning bids based on highest CPM -- Response time varies based on mocktioneer instance +The template-cache harness exercises plan compilation, HTTPS backend naming, +provider dispatch, mediation, and both ESI and inline delivery modes: -### Prebid -- **Real implementation** - makes actual HTTP calls -- Queries configured SSPs -- Returns real bids from real bidders -- Response time: varies (network dependent) +```bash +./scripts/template-cache-local-test.sh esi +./scripts/template-cache-local-test.sh inline +``` diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 9a371f805..85aa6f424 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::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ @@ -74,8 +76,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 ed199e6bf..6812b7421 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 6ce0a5ee3..385e8a0f0 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, +}; use trusted_server_core::cache_policy::EdgeCacheHeader; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::settings_from_config_blob; @@ -113,8 +115,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), @@ -625,3 +629,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 47cc609b2..e5ca3b083 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -27,7 +27,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 06a0a155f..a2b6973c9 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -100,7 +100,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::cache_policy::EdgeCacheHeader; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; @@ -182,8 +184,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; @@ -1308,8 +1312,9 @@ mod tests { use super::{ AppState, AuctionDispatch, EcContext, EdgeCacheHeader, HandlerFuture, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, TrustedServerApp, - build_per_request_services, build_state_from_settings, handle_publisher_request, - publisher_response_into_streaming_response, startup_error_router, + build_orchestrator_with_plan, build_per_request_services, build_state_from_settings, + compile_auction_plan, handle_publisher_request, publisher_response_into_streaming_response, + startup_error_router, }; use base64::Engine as _; use bytes::Bytes; @@ -1370,7 +1375,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] @@ -1378,7 +1382,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 "#, ) @@ -1436,12 +1443,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 "#, ) @@ -1490,8 +1499,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; @@ -1577,6 +1591,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 { @@ -2551,6 +2593,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, @@ -2775,7 +2821,7 @@ mod tests { [auction] enabled = true - providers = [] + providers = {} [creative_opportunities] gam_network_id = "99999" @@ -2802,11 +2848,13 @@ mod tests { .geo(Arc::new(crate::platform::FastlyPlatformGeo)) .client_info(ClientInfo::default()) .build(); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); let registry = Arc::new( - IntegrationRegistry::new(&settings).expect("should build integration registry"), + IntegrationRegistry::with_plan(&settings, Arc::clone(&plan)) + .expect("should build integration registry"), ); let orchestrator = Arc::new( - trusted_server_core::auction::build_orchestrator(&settings) + build_orchestrator_with_plan(plan, &settings) .expect("should build auction orchestrator"), ); diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index f2ff5d9e5..0151cbb16 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] @@ -19,6 +22,44 @@ fn default_port_for_scheme(scheme: &str) -> u16 { } } +#[derive(Debug, Clone, Eq, PartialEq)] +struct NormalizedBackendHost { + identity: String, + authority: String, + is_ip_literal: bool, +} + +/// Normalize URL-derived and direct hosts for transport and TLS use. +/// +/// `url::Url::host_str()` preserves brackets around IPv6 literals. Fastly's +/// backend target and HTTP authority require those brackets, while certificate +/// identity matching requires the bare address and SNI must not be sent for IP +/// literals. +#[inline] +fn normalize_backend_host(host: &str) -> NormalizedBackendHost { + let unbracketed = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + match unbracketed.parse::() { + Ok(std::net::IpAddr::V6(_)) => NormalizedBackendHost { + identity: unbracketed.to_owned(), + authority: format!("[{unbracketed}]"), + is_ip_literal: true, + }, + Ok(std::net::IpAddr::V4(_)) => NormalizedBackendHost { + identity: unbracketed.to_owned(), + authority: unbracketed.to_owned(), + is_ip_literal: true, + }, + Err(_) => NormalizedBackendHost { + identity: host.to_owned(), + authority: host.to_owned(), + is_ip_literal: false, + }, + } +} + /// Compute the Host header value for a backend request. /// /// For standard ports (443 for HTTPS, 80 for HTTP), returns just the hostname. @@ -29,54 +70,14 @@ fn default_port_for_scheme(scheme: &str) -> u16 { /// would generate URLs without the port when the Host header didn't include it. #[inline] fn compute_host_header(scheme: &str, host: &str, port: u16) -> String { + let host = normalize_backend_host(host).authority; if port == default_port_for_scheme(scheme) { - host.to_owned() + host } else { format!("{host}:{port}") } } -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,149 +174,32 @@ 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); - } - - 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'), + fn platform_spec(&self) -> PlatformBackendSpec { + PlatformBackendSpec { + scheme: self.scheme.to_owned(), + host: normalize_backend_host(self.host).identity, + 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), } - 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. /// @@ -323,13 +207,13 @@ impl<'a> BackendConfig<'a> { /// /// Returns an error if the host is empty. 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,12 +222,15 @@ 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 = normalize_backend_host(self.host); - let host_with_port = format!("{}:{}", self.host, target_port); + let host_with_port = format!("{}:{target_port}", host.authority); let host_header = self.host_header_override.map_or_else( - || compute_host_header(self.scheme, self.host, target_port), + || compute_host_header(self.scheme, &host.identity, target_port), str::to_owned, ); @@ -354,9 +241,12 @@ impl<'a> BackendConfig<'a> { .first_byte_timeout(self.first_byte_timeout) .between_bytes_timeout(self.between_bytes_timeout); if self.scheme.eq_ignore_ascii_case("https") { - builder = builder.enable_ssl().sni_hostname(self.host); + builder = builder.enable_ssl(); + if !host.is_ip_literal { + builder = builder.sni_hostname(&host.identity); + } if self.certificate_check { - builder = builder.check_certificate(self.host); + builder = builder.check_certificate(&host.identity); } else { log::warn!("INSECURE: certificate check disabled for backend: {backend_name}"); } @@ -474,7 +364,12 @@ impl<'a> BackendConfig<'a> { #[cfg(test)] mod tests { - use super::{BackendConfig, MAX_BACKEND_NAME_LEN, SPEC_DIGEST_HEX_LEN, compute_host_header}; + use trusted_server_core::platform::BackendNamingError; + + use super::{ + BackendConfig, MAX_BACKEND_NAME_LEN, SPEC_DIGEST_HEX_LEN, compute_host_header, + normalize_backend_host, + }; /// Assert a computed name is `backend__` and stays within /// Fastly's length limit. The digest is what makes the name injective, so @@ -503,6 +398,63 @@ mod tests { } // Tests for compute_host_header - the fix for port preservation in Host header + #[test] + fn ipv6_hosts_are_bracketed_only_for_authority_values() { + let bare = normalize_backend_host("2001:db8::1"); + let bracketed = normalize_backend_host("[2001:db8::1]"); + assert_eq!(bare, bracketed); + assert_eq!(bare.identity, "2001:db8::1"); + assert_eq!(bare.authority, "[2001:db8::1]"); + assert!(bare.is_ip_literal, "IPv6 must not be sent as TLS SNI"); + assert_eq!( + normalize_backend_host("cdn.example.com"), + super::NormalizedBackendHost { + identity: "cdn.example.com".to_string(), + authority: "cdn.example.com".to_string(), + is_ip_literal: false, + } + ); + assert_eq!( + compute_host_header("https", "[2001:db8::1]", 443), + "[2001:db8::1]" + ); + assert_eq!( + compute_host_header("https", "[2001:db8::1]", 8443), + "[2001:db8::1]:8443" + ); + } + + #[test] + fn url_derived_ipv6_host_uses_bare_tls_identity_without_sni() { + let (scheme, url_host, port) = + BackendConfig::parse_origin("https://[2001:db8::7]:8443/openrtb") + .expect("should parse IPv6 provider URL"); + assert_eq!(scheme, "https"); + assert_eq!(url_host, "[2001:db8::7]"); + assert_eq!(port, Some(8443)); + + let normalized = normalize_backend_host(&url_host); + assert_eq!(normalized.identity, "2001:db8::7"); + assert_eq!(normalized.authority, "[2001:db8::7]"); + assert!( + normalized.is_ip_literal, + "IP literals must omit TLS SNI while retaining a bare certificate identity" + ); + + let from_url_name = BackendConfig::new(&scheme, &url_host) + .port(port) + .predict_name() + .expect("should predict URL-derived IPv6 backend name"); + let from_bare_name = BackendConfig::new(&scheme, "2001:db8::7") + .port(port) + .predict_name() + .expect("should predict bare IPv6 backend name"); + assert_eq!( + from_url_name, from_bare_name, + "URL and direct IPv6 paths must preserve backend naming parity" + ); + } + #[test] fn host_header_includes_port_for_non_standard_https() { assert_eq!( @@ -584,8 +536,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 +546,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 +568,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 +599,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 +619,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 +629,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 +655,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 638aed82b..e612830b9 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, }; use trusted_server_core::settings::TrustedClientIpConfig; @@ -150,6 +151,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) @@ -160,83 +166,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; - } - SUB_QUANTUM_LADDER_MS - .into_iter() - .find(|&rung| rung <= remaining_ms) - .unwrap_or(0) -} - impl PlatformBackend for FastlyPlatformBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Fastly + } + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + // Use the same host normalization as registration. In particular, + // URL-derived IPv6 hosts arrive bracketed, but both forms must predict + // the backend that `ensure` actually registers. backend_config_from_spec(spec) .predict_name() .change_context(PlatformError::Backend) @@ -247,28 +185,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) - } } // --------------------------------------------------------------------------- @@ -544,6 +460,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)] @@ -1136,8 +1060,72 @@ mod tests { ); } + #[test] + fn bracketed_ipv6_predict_name_matches_bare_and_ensured_backend_name() { + let backend = FastlyPlatformBackend; + let bracketed = PlatformBackendSpec { + scheme: "https".to_string(), + host: "[2001:db8::9]".to_string(), + port: Some(8443), + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(750), + between_bytes_timeout: Duration::from_millis(750), + discriminator: Some("ipv6-provider".to_string()), + }; + let mut bare = bracketed.clone(); + bare.host = "2001:db8::9".to_string(); + + let predicted = backend + .predict_name(&bracketed) + .expect("should predict bracketed IPv6 backend name"); + let bare_predicted = backend + .predict_name(&bare) + .expect("should predict bare IPv6 backend name"); + let ensured = backend + .ensure(&bracketed) + .expect("should register bracketed IPv6 backend"); + + assert_eq!(predicted, bare_predicted); + assert_eq!(predicted, ensured); + } + // --- 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 f24b5b717..b87507918 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::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ @@ -71,8 +73,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), @@ -856,6 +860,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 5caab273b..b3b569647 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -31,6 +31,8 @@ const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES"; const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES"; const GAM_ATTRIBUTION_ENV: &str = "TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED"; const AD_TEMPLATES_ENABLED_ENV: &str = "TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__ENABLED"; +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, @@ -38,7 +40,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"); @@ -52,6 +54,13 @@ fn migrated_legacy_project() -> MigratedProject { document["auction"]["sanitize_creatives"] = value(false); document["creative_opportunities"]["enabled"] = value(true); document["creative_opportunities"]["gam_network_id"] = value("123456789"); + 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 { @@ -74,8 +83,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) @@ -117,8 +126,8 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() { } #[test] -fn migrated_legacy_config_applies_boolean_environment_overrides() { - let project = migrated_legacy_project(); +fn migrated_config_applies_boolean_environment_overrides() { + let project = migrated_project(); let output = Command::new(env!("CARGO_BIN_EXE_ts")) .args(["config", "push", "--adapter", "axum", "--manifest"]) .arg(&project.manifest_path) @@ -166,8 +175,8 @@ fn migrated_legacy_config_applies_boolean_environment_overrides() { } #[test] -fn migrated_legacy_config_applies_sanitize_creatives_environment_override() { - let project = migrated_legacy_project(); +fn migrated_config_applies_sanitize_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) @@ -209,8 +218,8 @@ fn migrated_legacy_config_applies_sanitize_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) @@ -279,8 +288,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); @@ -293,3 +302,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 69c19475b..8a745eabc 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -1,583 +1,181 @@ -# Auction Orchestration System +# Auction orchestration + +The auction module compiles operator configuration into one immutable plan, +routes browser demand to providers, runs provider requests concurrently where +the adapter permits it, and returns normalized OpenRTB bids. -A flexible, extensible framework for managing multi-provider header bidding auctions with support for parallel execution and mediation. +The maintained operator guide is +[`docs/guide/auction-orchestration.md`](../../../../docs/guide/auction-orchestration.md). +This file describes the code layout and runtime flow for contributors. -## Overview - -The auction orchestration system allows you to: -- Run multiple auction providers (Prebid, Amazon APS, etc.) in parallel or sequentially -- Implement mediation strategies where a primary ad server makes the final decision -- Configure different auction flows for different scenarios -- Easily add new auction providers - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Auction Orchestrator │ -│ - Manages auction workflow & sequencing │ -│ - Combines bids from multiple sources │ -│ - Applies business logic │ -└─────────────────────────────────────────────────────────┘ - │ - │ uses - ▼ -┌─────────────────────────────────────────────────────────┐ -│ AuctionProvider Trait │ -│ - request_bids() async │ -│ - parse_response() │ -│ - provider_name() │ -│ - timeout_ms() │ -│ - is_enabled() │ -└─────────────────────────────────────────────────────────┘ - │ - ┌─────────────────┼─────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ Prebid │ │ Amazon │ │ AdServer │ - │ Provider │ │ APS │ │ Mock │ - └──────────┘ └──────────┘ └──────────┘ +## Runtime flow + +```mermaid +flowchart TB + A[Adapter app.rs routes POST /auction] --> B[endpoints::handle_auction] + B --> C[endpoints::convert_tsjs_to_auction_request] + C --> D[routing::route_auction] + D --> E[provider::GenericOpenRtbProvider builds requests] + E --> F[orchestrator::AuctionOrchestrator dispatches providers] + F --> G[Provider responses are normalized] + G --> H{Mediator configured?} + H -->|Yes| I[Mediator selects bids] + H -->|No| J[Orchestrator ranks bids locally] + I --> K[formats::convert_to_openrtb_response] + J --> K + K --> L[HTTP 200 OpenRTB response] ``` -## Request Flow +Each adapter owns transport routing in its `app.rs`. Core request handling stays +in `auction::endpoints`, so no provider or profile depends on Fastly types. -When a request arrives at the `/auction` endpoint, it goes through the following steps: +`handle_auction` performs these steps: -``` -┌──────────────────────────────────────────────────────────────────────┐ -│ 1. HTTP POST /auction │ -│ - Body: AdRequest (Prebid.js/tsjs format) │ -│ - Headers: User-Agent, cookies, etc. │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 2. Route Matching (crates/trusted-server-adapter-fastly/src/main.rs)│ -│ - Pattern: (Method::POST, "/auction") │ -│ - Handler: handle_auction(settings, &orchestrator, │ -│ &runtime_services, req) │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 3. Parse Request Body (mod.rs:149) │ -│ - Deserialize JSON → AdRequest struct │ -│ - Extract ad units with media types │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 4. Generate User IDs (mod.rs:206-214) │ -│ - Create/retrieve EC ID (persistent) │ -│ - Generate fresh ID (per-request) │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 5. Transform Request Format (mod.rs:216-240) │ -│ - AdRequest → AuctionRequest │ -│ - AdUnit.code → AdSlot.id │ -│ - mediaTypes.banner.sizes → AdFormat[] │ -│ - Build PublisherInfo, UserInfo, DeviceInfo │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 6. Use Provided Orchestrator (mod.rs:150) │ -│ - Reused across requests from startup construction │ -│ - Contains all registered providers (APS, Prebid, etc.) │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 7. Create Auction Context (mod.rs:172-176) │ -│ - Attach settings │ -│ - Attach original request │ -│ - Set timeout from config │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 8. Run Auction Strategy (orchestrator.rs:42) │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ Strategy: parallel_only │ │ -│ │ 1. Launch all bidders concurrently │ │ -│ │ 2. Wait for all responses │ │ -│ │ 3. Select highest bid per slot │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ Strategy: parallel_mediation │ │ -│ │ 1. Launch all bidders concurrently │ │ -│ │ 2. Collect all bids │ │ -│ │ 3. Send to mediator for final decision │ │ -│ └────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 9. Each Provider Processes Request │ -│ - Transform AuctionRequest → Provider OpenRTB request │ -│ - Send HTTP request to provider endpoint │ -│ - Parse provider response │ -│ - Transform → AuctionResponse with Bid[] │ -│ - Return to orchestrator │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 10. Select Winning Bids (orchestrator.rs:363-385) │ -│ - For each slot, find highest CPM bid │ -│ - Create HashMap │ -│ - Log winning selections │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 11. Transform to OpenRTB Response (mod.rs:274-322) │ -│ - Build seatbid array (one per winning bid) │ -│ - Sanitize creative HTML when enabled (opt-in) │ -│ - Rewrite creative HTML when enabled (default) │ -│ - Add orchestrator metadata (timing, strategy, bid count) │ -└──────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────┐ -│ 12. Return HTTP Response │ -│ - Status: 200 OK │ -│ - Content-Type: application/json │ -│ - Body: OpenRTB BidResponse │ -└──────────────────────────────────────────────────────────────────────┘ -``` +1. Enforce the body limit and parse the Trusted Server ad-unit request. +2. Apply the disabled-auction and consent gates before provider work. +3. Consume the request's existing EC and consent context. The endpoint does not + generate an EC ID. +4. Convert the request with `convert_tsjs_to_auction_request`. +5. Route slots and bidder params through the compiled `AuctionPlan`. +6. Run the plan-backed orchestrator and optional mediator. +7. Build the OpenRTB response with `convert_to_openrtb_response`. -### Step-by-Step Breakdown - -#### 1. Request Arrival -Client (browser, Prebid.js, tsjs) sends a POST request to `/auction` with ad unit definitions: - -```json -{ - "adUnits": [ - { - "code": "header-banner", - "mediaTypes": { - "banner": { - "sizes": [[728, 90], [970, 250]] - } - } - } - ] -} -``` +## Configuration boundary -#### 2. Format Transformation -The system transforms the Prebid.js format into an internal `AuctionRequest`: - -```rust -// From: AdUnit with sizes [[728, 90], [970, 250]] -// To: AdSlot with formats -AdSlot { - id: "header-banner", - formats: vec![ - AdFormat { width: 728, height: 90, media_type: Banner }, - AdFormat { width: 970, height: 250, media_type: Banner }, - ], - floor_price: None, - targeting: HashMap::new(), -} -``` +`auction::compile_auction_plan` is the single settings-to-plan boundary used by +startup and operator validation. It validates: -#### 3. Provider Execution -Each registered provider (APS, Prebid, etc.) receives the `AuctionRequest` and: -- Transforms it to the provider's OpenRTB request format -- Makes HTTP request to their endpoint -- Parses the response -- Returns `AuctionResponse` with `Bid[]` - -For example, APS provider: -```rust -// Transform AuctionRequest → APS OpenRTB request -// - ext.account = configured account_id -// - ext.sdk = { source: "prebid", version: "2.2.0" } -// - banner slots become secure impressions with matching formats/floors -// - existing consent, identity, device, and geo privacy gates apply - -// HTTP POST to https://aps.example.com/e/pb/bid -// Parse decoded-price response → AuctionResponse with a typed renderer -``` +- provider IDs, protocols, profiles, endpoints, timeouts, and routing modes; +- bidder-to-provider ownership; +- profile-specific configuration; +- mediator and request-signing references; +- bounded configuration values. -#### 4. Response Assembly -The orchestrator collects all bids and creates an OpenRTB response: - -```json -{ - "id": "auction-response", - "seatbid": [ - { - "seat": "aps", - "bid": [ - { - "id": "fictional-selected-bid-id", - "impid": "header-banner", - "price": 2.5, - "w": 728, - "h": 90, - "ext": { - "trusted_server": { - "renderer": { - "type": "aps", - "version": 1, - "accountId": "example-account", - "bidId": "fictional-selected-bid-id", - "tagType": "iframe", - "creativeUrl": "https://creative.example/render", - "aaxResponse": "", - "width": 728, - "height": 90 - } - } - } - } - ] - } - ], - "ext": { - "orchestrator": { - "strategy": "parallel_only", - "bidders": 1, - "total_bids": 1, - "time_ms": 5 - } - } -} -``` +Adapters then call `AuctionPlan::validate_for_target` for backend naming, +fan-out support, and target resource limits. -With `[auction].sanitize_creatives = true` (opt-in, default `false`), -executable markup is stripped with its inner content before delivery. With -`[auction].rewrite_creatives = true` (the default), each auction delivery path -rewrites eligible URLs through the first-party proxy (`/first-party/proxy`) and -removes bidder `` elements. The `POST /auction` response also injects the -creative runtime; the publisher SSAT inline path uses absolute first-party URLs -without injecting that bundle. With both disabled, the creative ships exactly -as the bidder returned it. In every mode, creatives over the 1 MiB cap are -rejected. - -## Route Registration & Endpoints - -### Auction-Related Routes - -The trusted-server handles several types of routes defined in `crates/trusted-server-adapter-fastly/src/main.rs`: - -| Route | Method | Handler | Purpose | Line | -|---------------------------|--------|--------------------------------|--------------------------------------------------|------| -| `/auction` | POST | `handle_auction()` | Main auction endpoint (Prebid.js/tsjs format) | 84 | -| `/first-party/proxy` | GET | `handle_first_party_proxy()` | Proxy creatives through first-party domain | 84 | -| `/first-party/click` | GET | `handle_first_party_click()` | Track clicks on ads | 85 | -| `/first-party/sign` | GET/POST | `handle_first_party_proxy_sign()` | Generate signed URLs for creatives | 86 | -| `/first-party/proxy-rebuild` | GET/POST | `handle_first_party_proxy_rebuild()` | Re-sign mutated click URLs (GET 302s for the opaque-origin click guard) | 89 | -| `/static/tsjs=*` | GET | `handle_tsjs_dynamic()` | Serve tsjs library (Prebid.js alternative) | 66 | -| `/.well-known/ts.jwks.json` | GET | `handle_jwks_endpoint()` | Public key distribution for request signing | 71 | -| `/verify-signature` | POST | `handle_verify_signature()` | Verify signed requests | 74 | -| `/_ts/admin/keys/rotate` | POST | `handle_rotate_key()` | Rotate signing keys (admin only) | 77 | -| `/_ts/admin/keys/deactivate` | POST | `handle_deactivate_key()` | Deactivate signing keys (admin only) | 78 | -| `/integrations/*` | * | Integration Registry | Provider-specific endpoints (Prebid, etc.) | 92 | -| `*` (fallback) | * | `handle_publisher_request()` | Proxy to publisher origin | 108 | - -### How Routing Works - -#### 1. Main Router (main.rs) -The Fastly Compute entrypoint uses pattern matching on `(Method, path)` tuples: - -```rust -let result = match (method, path.as_str()) { - // Auction endpoint - (Method::POST, "/auction") => { - handle_auction(&settings, &orchestrator, &runtime_services, req).await - }, - - // First-party endpoints - (Method::GET, "/first-party/proxy") => handle_first_party_proxy(&settings, req).await, - - // Integration registry (dynamic routes) - (m, path) if integration_registry.has_route(&m, path) => { - integration_registry.handle_proxy(&m, path, &settings, req).await - }, - - // Fallback to publisher origin - _ => handle_publisher_request(&settings, &integration_registry, &runtime_services, req), -} -``` +A plan-backed orchestrator contains generic OpenRTB providers compiled from the +plan. `AuctionOrchestrator::register_provider` and the old concrete Prebid +provider remain test-only parity code. They are not extension APIs. -#### 2. Integration Registry (Dynamic Routes) -Some integrations register their own routes dynamically. For example, Prebid registers `/integrations/prebid/auction`: - -```rust -// In integrations/prebid.rs -impl Integration for PrebidIntegration { - fn routes(&self) -> Vec { - vec![ - IntegrationRoute { - path: "/integrations/prebid/auction", - method: Method::POST, - handler: handle_prebid_auction, - } - ] - } -} -``` +## Routing -The integration registry checks if a route matches any registered integration routes before falling back to the publisher origin. - -#### 3. Route Priority -Routes are matched in this order: -1. **Exact top-level routes** (`/auction`, `/first-party/proxy`, etc.) -2. **Admin routes** (`/_ts/admin/*`) -3. **Integration routes** (`/integrations/*`) -4. **Fallback to publisher origin** (all other paths) - -This ensures auction and first-party endpoints take precedence over publisher content. - -### Auction Endpoint Deep Dive - -The `/auction` endpoint is the primary entry point for auctions: - -**Input Format (Prebid.js compatible):** -```json -{ - "adUnits": [ - { - "code": "div-id", - "mediaTypes": { - "banner": { - "sizes": [[300, 250], [728, 90]] - } - } - } - ], - "config": { /* optional Prebid.js config */ } -} -``` +`routing::route_auction` normalizes the browser `trustedServer` envelope and +produces one `ProviderAuctionInput` per provider. -**Output Format (OpenRTB 2.x):** -```json -{ - "id": "auction-response", - "seatbid": [ - { - "seat": "bidder-name", - "bid": [ - { - "id": "bid-id", - "impid": "div-id", - "price": 2.5, - "adm": "", - "w": 300, - "h": 250 - } - ] - } - ], - "ext": { - "orchestrator": { - "strategy": "parallel_only", - "bidders": 2, - "total_bids": 3, - "time_ms": 150 - } - } -} -``` +- `explicit` sends a slot only when it has bidder demand assigned to that + provider, or trusted stored-request demand where the profile supports it. +- `all_eligible` sends every compatible banner slot without copying another + provider's bidder params. +- `prebid-server` requires `explicit`. PBS rejects impressions that have neither + bidder demand nor a stored-request reference. +- APS normally uses `all_eligible` because APS participates across eligible + inventory without browser bidder params. -**Key Transformations:** -- `adUnits[].code` → `seatbid[].bid[].impid` (slot identifier) -- `mediaTypes.banner.sizes` → evaluated by providers, winning size in `bid.w` and `bid.h` -- Creative HTML: `[auction].sanitize_creatives = true` (opt-in) strips executable markup; `[auction].rewrite_creatives = true` (default) rewrites eligible URLs to `/first-party/proxy` in both delivery paths (with creative runtime injection on `POST /auction` only); with both disabled the creative ships as the bidder returned it -- Multiple bids per slot become separate `seatbid` entries -- Orchestrator metadata added in `ext.orchestrator` +Each `[auction.bidders.]` route has one provider owner. Unlisted page +bidders remain browser demand. -## Key Concepts +## Provider execution -### Auction Provider -Implements the `AuctionProvider` trait to integrate with a specific SSP/ad exchange. +`provider::GenericOpenRtbProvider` owns the shared transport path for the +`standard`, `prebid-server`, and `aps` profiles. Profiles receive routed and +privacy-approved facts, not the raw inbound request. -### Auction Flow -A named configuration that defines: -- Which providers participate -- Execution strategy (parallel mediation or parallel only) -- Timeout settings -- Optional mediator +The orchestrator launches all eligible providers before collecting responses. +It uses adapter `PlatformHttpClient` handles and predicted backend names for +correlation. Provider launch, transport, HTTP, parse, and admission failures are +provider-local when another provider can continue. -### Orchestrator -Manages the execution of an auction flow, coordinates providers, and collects results. +When no mediator is configured, the orchestrator selects the highest decoded +CPM per slot and applies floors locally. When a mediator is configured, it sends +normalized provider responses to the separately registered mediator and falls +back to local ranking when mediation cannot run. -## Auction Strategies +## Response admission -### 1. Parallel + Mediation (Recommended) -**Use case:** Header bidding with ad server mediation +Providers normalize successful upstream bids into `auction::types::Bid`. +Admission checks keep malformed or unrequested bids out of ranking. Aggregate +metadata reports bounded rejection counts without retaining raw upstream bid +payloads. -```toml -[auction] -enabled = true -providers = ["prebid", "aps"] -mediator = "adserver_mock" # Setting mediator enables parallel mediation strategy -timeout_ms = 2000 -``` - -**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 - -### 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 -``` +Notification suppression runs after normalization and matches exact returned +OpenRTB seats. Provider response identity uses the configured provider ID, such +as `pbs-main`. -**Flow:** -1. All providers run in parallel -2. Highest bid wins -3. No mediation server involved +## Creative delivery -## Configuration +`formats::convert_to_openrtb_response` assembles the direct `POST /auction` +response. -### Configuration +- `sanitize_creatives = true` strips executable markup. It is opt-in. +- `rewrite_creatives = true` rewrites eligible URLs through first-party routes + and removes bidder `` elements. It is enabled by default. +- The publisher inline delivery path uses absolute first-party URLs without + injecting the direct endpoint's creative runtime. +- Creatives over the configured hard cap are rejected. -All auction settings are configured directly under `[auction]`: +## Example plan ```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 - -Each provider has its own configuration section: - -```toml -[integrations.prebid] enabled = true -server_url = "https://prebid-server.example.com" -timeout_ms = 1000 - -[integrations.aps] -enabled = true -mock = true # Set to false for real integration -timeout_ms = 800 - -[integrations.adserver_mock] -enabled = true -endpoint = "http://localhost:6767/adserver/mediate" -timeout_ms = 500 -``` - -## Adding a New Provider - -1. Create a new file in `src/auction/providers/your_provider.rs` - -```rust -use async_trait::async_trait; -use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; -use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse}; -use crate::platform::PlatformResponse; - -pub struct YourAuctionProvider { - config: YourConfig, -} - -#[async_trait(?Send)] -impl AuctionProvider for YourAuctionProvider { - fn provider_name(&self) -> &'static str { - "your_provider" - } - - async fn request_bids( - &self, - request: &AuctionRequest, - _context: &AuctionContext<'_>, - ) -> Result> { - // 1. Transform AuctionRequest to your provider's format - // 2. Launch through services.http_client().send_async(...) - // 3. Wrap the handle with ProviderRequestOutcome::pending(...) - todo!() - } - - async fn parse_response( - &self, - response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - // 4. Parse PlatformResponse into AuctionResponse - todo!() - } - - fn timeout_ms(&self) -> u32 { - self.config.timeout_ms - } - - fn is_enabled(&self) -> bool { - self.config.enabled - } -} -``` - -2. Register the provider in `src/auction/providers/mod.rs` +timeout_ms = 2000 -3. Configure it in `trusted-server.toml` +[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.bidders.example-server] +provider = "pbs-main" + +[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-account" } +``` + +Provider endpoints must be absolute HTTPS URLs. Replace all example values +before enabling an auction. + +## Code map + +- `mod.rs` compiles plans and builds the shared orchestrator. +- `endpoints.rs` handles `POST /auction` and converts the browser request. +- `plan.rs` owns plan validation and target capability checks. +- `profile.rs` owns typed OpenRTB profile configuration. +- `routing.rs` assigns slots and bidder params to providers. +- `openrtb.rs` builds shared requests and parses standard responses. +- `provider.rs` runs plan-backed provider requests and profile-specific parsing. +- `orchestrator.rs` owns fan-out, deadlines, mediation, and local ranking. +- `formats.rs` builds direct endpoint responses and processes creatives. +- `types.rs` contains normalized auction request, response, slot, and bid types. ## Testing -### Mock Providers - -APS and adserver_mock providers are used for testing the orchestration pattern: - -- **APS Mock**: Returns mock bids with Amazon branding -- **AdServer Mock**: Acts as mediator by calling mocktioneer's mediation endpoint, selects winning bids based on highest CPM - -Set `mock = false` in APS config when real APS integration is ready. - -### Example Test Flow - -```rust -let orchestrator = AuctionOrchestrator::new(config); -orchestrator.register_provider(Arc::new(PrebidAuctionProvider::try_new(prebid_config)?)); -orchestrator.register_provider(Arc::new(ApsAuctionProvider::new(aps_config))); +Use `compile_auction_plan` in tests, then construct the orchestrator and +integration registry from the same `Arc`. Profile tests should +cover typed configuration, exact request output, response admission, routing, +provider-local failures, and target validation. -let result = orchestrator.run_auction(&request, &context, &services).await?; +Run target-matched aliases rather than bare workspace tests: -// Check results -assert_eq!(result.winning_bids.len(), 2); -assert!(result.total_time_ms < 2000); +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin ``` - -## Performance Considerations - -- **Parallel Execution**: Providers are launched concurrently via `select()` over `PendingRequest`s; responses are processed as they become ready within the auction deadline -- **Timeouts**: Each provider has independent timeout; global timeout enforced at flow level -- **Error Handling**: Provider failures don't fail entire auction; partial results returned - -## Related Files - -- `src/auction/mod.rs` - Module exports -- `src/auction/types.rs` - Core auction types -- `src/auction/provider.rs` - Provider trait definition -- `src/auction/orchestrator.rs` - Orchestration logic -- `src/auction/config.rs` - Configuration types -- `src/auction/providers/` - Provider implementations - -## Questions? - -See the main project [README](../../../../README.md) or [integration guide](../../../../docs/guide/integration-guide.md). diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index fdf387e93..b21d185a7 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -179,6 +179,49 @@ 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 observation = AuctionObservationContext::from_auction_request( + AuctionSource::AuctionApi, + &auction_request, + ec_context, + ); + let elapsed_ms = observation.elapsed_ms(); + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Skipped { + reason: "auction_disabled", + elapsed_ms, + }, + ) + }) + .await; + + 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 @@ -293,6 +336,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, }; @@ -570,6 +614,7 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; use crate::platform::test_support::{ NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient, @@ -632,7 +677,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" } @@ -670,7 +715,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for TemplateSwitchProbeProvider { fn provider_name(&self) -> &'static str { - "template_switch_probe" + "template-switch-probe" } async fn request_bids( @@ -722,7 +767,7 @@ mod tests { #[tokio::test] async fn direct_auction_remains_available_when_templates_are_disabled() { let settings_toml = format!( - "{}\n[auction]\nenabled = true\nproviders = [\"template_switch_probe\"]\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", + "{}\n[auction]\nenabled = true\n\n[auction.providers.template-switch-probe]\nprotocol = \"openrtb-2.6\"\nendpoint = \"https://bidder.example/auction\"\nrouting = \"all_eligible\"\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", crate_test_settings_str() ); let settings = Settings::from_toml(&settings_toml) @@ -779,6 +824,126 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn disabled_auction_endpoint_emits_skipped_telemetry_without_provider_work() { + let settings = create_test_settings(); + let config = AuctionConfig { + enabled: false, + providers: AuctionConfig::legacy_provider_map(&["panic_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(PanicOnBidProvider)); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with_telemetry(Arc::clone(&telemetry_sink)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let body = json!({ + "adUnits": [{ + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + }] + }); + let request = Request::builder() + .method("POST") + .uri("https://test-publisher.example/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize disabled-auction body"), + )) + .expect("should build disabled-auction request"); + + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + request, + ) + .await + .expect("disabled auction should return a no-bid response"); + + assert_eq!( + response.status(), + StatusCode::OK, + "disabled auction should return a 200 no-bid response" + ); + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + assert_eq!(batches.len(), 1, "should emit one telemetry batch"); + let rows = batches[0].rows(); + assert_eq!(rows.len(), 1, "should emit one skipped summary row"); + assert_eq!(rows[0].event_kind, "summary", "should emit a summary row"); + assert_eq!(rows[0].terminal_status.as_deref(), Some("skipped")); + assert_eq!( + rows[0].terminal_reason.as_deref(), + Some("auction_disabled"), + "should identify the disabled auction policy" + ); + } + + #[tokio::test] + async fn all_planned_launch_failures_return_bad_gateway_and_execution_failed_telemetry() { + let settings_toml = format!( + "{}\n[auction]\nenabled = true\n\n[auction.providers.launch-fail]\nprotocol = \"openrtb-2.6\"\nprofile = \"standard\"\nendpoint = \"https://bidder.example/auction\"\nrouting = \"all_eligible\"\n", + crate_test_settings_str() + ); + let settings = + Settings::from_toml(&settings_toml).expect("should parse launch-failure settings"); + let plan = Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile launch-failure plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with_telemetry(Arc::clone(&telemetry_sink)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let body = json!({ + "adUnits": [{ + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + }] + }); + let request = Request::builder() + .method("POST") + .uri("https://test-publisher.example/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize launch-failure body"), + )) + .expect("should build launch-failure request"); + + let error = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + request, + ) + .await + .expect_err("all planned launch failures should fail the auction endpoint"); + + assert_eq!( + error.current_context().status_code(), + StatusCode::BAD_GATEWAY + ); + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + assert_eq!(batches.len(), 1, "should emit one telemetry batch"); + let rows = batches[0].rows(); + assert_eq!(rows.len(), 1, "should emit one execution-failure summary"); + assert_eq!(rows[0].event_kind, "summary"); + assert_eq!(rows[0].terminal_status.as_deref(), Some("execution_failed")); + assert_eq!(rows[0].terminal_reason.as_deref(), Some("execution_failed")); + } + #[tokio::test] async fn auction_endpoint_consent_gate_returns_no_bid_without_contacting_providers() { // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run @@ -788,7 +953,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() @@ -872,7 +1037,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" } @@ -916,7 +1081,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 e09912aab..571d9d484 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -552,6 +552,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, }; @@ -560,7 +564,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() @@ -575,6 +580,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(), @@ -622,6 +649,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, @@ -715,6 +743,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(); @@ -1630,6 +1683,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..0a142c1de --- /dev/null +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -0,0 +1,888 @@ +//! 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, BTreeSet, HashMap}; + +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; + +/// Fixed reasons why an upstream bid failed response admission. +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum BidRejectionReason { + InvalidBid, + UnrequestedImpression, + DimensionMismatch, + AmbiguousDimensions, +} + +impl BidRejectionReason { + fn as_str(self) -> &'static str { + match self { + Self::InvalidBid => "invalid_bid", + Self::UnrequestedImpression => "unrequested_impression", + Self::DimensionMismatch => "dimension_mismatch", + Self::AmbiguousDimensions => "ambiguous_dimensions", + } + } +} + +/// Bounded aggregate diagnostics for rejected upstream bids. +#[derive(Debug, Default)] +pub(crate) struct ResponseAdmissionDiagnostics { + rejected_bid_count: u32, + reason_counts: BTreeMap, +} + +impl ResponseAdmissionDiagnostics { + /// Record one rejected bid without retaining upstream payload data. + pub(crate) fn record(&mut self, reason: BidRejectionReason) { + self.rejected_bid_count = self.rejected_bid_count.saturating_add(1); + let count = self.reason_counts.entry(reason).or_default(); + *count = count.saturating_add(1); + } + + /// Attach fixed-cardinality rejection counts to a provider response. + pub(crate) fn attach_to(self, response: &mut AuctionResponse) { + if self.rejected_bid_count == 0 { + return; + } + let reasons = self + .reason_counts + .into_iter() + .map(|(reason, count)| (reason.as_str().to_string(), json!(count))) + .collect::>(); + response.metadata.insert( + "response_admission".to_string(), + json!({ + "rejected_bid_count": self.rejected_bid_count, + "rejection_reasons": reasons, + }), + ); + } +} + +/// Parse an optional positive `OpenRTB` bid dimension. +pub(crate) fn parse_optional_bid_dimension( + value: &Value, + key: &str, +) -> Result, BidRejectionReason> { + let Some(raw) = value.get(key) else { + return Ok(None); + }; + raw.as_u64() + .and_then(|dimension| u32::try_from(dimension).ok()) + .filter(|dimension| *dimension > 0) + .map(Some) + .ok_or(BidRejectionReason::InvalidBid) +} + +/// Validate explicit dimensions or infer them from one routed banner format. +pub(crate) fn resolve_bid_dimensions( + input: &ProviderAuctionInput, + slot_id: &str, + width: Option, + height: Option, +) -> Result<(u32, u32), BidRejectionReason> { + let slot = input + .slots() + .iter() + .find(|slot| slot.slot().id == slot_id) + .ok_or(BidRejectionReason::UnrequestedImpression)?; + let dimensions = slot + .slot() + .formats + .iter() + .map(|format| (format.width, format.height)) + .collect::>(); + + if let (Some(width), Some(height)) = (width, height) { + return dimensions + .contains(&(width, height)) + .then_some((width, height)) + .ok_or(BidRejectionReason::DimensionMismatch); + } + + if dimensions.len() != 1 { + return Err(BidRejectionReason::AmbiguousDimensions); + } + let inferred = dimensions + .first() + .copied() + .expect("should have one routed banner format"); + if width.is_some_and(|width| width != inferred.0) + || height.is_some_and(|height| height != inferred.1) + { + return Err(BidRejectionReason::DimensionMismatch); + } + Ok(inferred) +} + +/// Result of request construction before transport. +#[derive(Debug)] +#[allow( + clippy::large_enum_variant, + reason = "Ready carries the full built request by design" +)] +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> { + debug_assert_eq!( + request.imp.len(), + input.slots().len(), + "should keep one impression per routed slot" + ); + for (imp, slot) in request.imp.iter_mut().zip(input.slots()) { + let bidder = slot + .bidder_params() + .iter() + .filter_map(|(bidder, params)| { + let mut params = params.clone(); + plan.override_engine + .apply_routed(bidder.as_str(), slot.prebid_zone(), &mut params); + params + .as_object() + .is_some_and(|params| !params.is_empty()) + .then(|| (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() || !slot.bidder_params().is_empty() { + 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) + .with_metadata("error_type", json!("parse_response")); + }; + match response.get("cur") { + None => {} + Some(Value::String(currency)) if currency.eq_ignore_ascii_case(DEFAULT_CURRENCY) => {} + Some(Value::String(currency)) => { + return AuctionResponse::no_bid(provider_id, response_time_ms) + .with_metadata("unsupported_currency", json!(currency)); + } + Some(_) => { + return AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("parse_response")); + } + } + let mut diagnostics = ResponseAdmissionDiagnostics::default(); + 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 { + match extract_standard_bid(value, returned_seat, input) { + Ok(bid) => bids.push(bid), + Err(reason) => diagnostics.record(reason), + } + } + } + let mut parsed = if bids.is_empty() { + AuctionResponse::no_bid(provider_id, response_time_ms) + } else { + AuctionResponse::success(provider_id, bids, response_time_ms) + }; + diagnostics.attach_to(&mut parsed); + parsed +} + +fn extract_standard_bid( + value: &Value, + returned_seat: Option<&str>, + input: &ProviderAuctionInput, +) -> Result { + let slot_id = value + .get("impid") + .and_then(Value::as_str) + .filter(|slot_id| !slot_id.is_empty()) + .ok_or(BidRejectionReason::InvalidBid)? + .to_string(); + let width = parse_optional_bid_dimension(value, "w")?; + let height = parse_optional_bid_dimension(value, "h")?; + let (width, height) = resolve_bid_dimensions(input, &slot_id, width, height)?; + let price = value + .get("price") + .and_then(Value::as_f64) + .filter(|price| price.is_finite() && *price >= 0.0) + .ok_or(BidRejectionReason::InvalidBid)?; + let creative = value + .get("adm") + .and_then(Value::as_str) + .filter(|creative| !creative.is_empty()) + .map(str::to_string) + .ok_or(BidRejectionReason::InvalidBid)?; + Ok(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..d322102e6 --- /dev/null +++ b/crates/trusted-server-core/src/auction/openrtb/test_executor.rs @@ -0,0 +1,107 @@ +//! 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("error_type", json!("http_status")) + .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("error_type", json!("parse_response")) + .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..83ed9e77e --- /dev/null +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -0,0 +1,1243 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +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, BidderId, BidderRouteConfig, NotificationConfig, + ProviderConfig, ProviderId, RoutingMode, +}; +use crate::auction::provider::{GenericOpenRtbProvider, ProviderRequestOutcome}; +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::PlatformHttpClient; +use crate::platform::test_support::{ + HashMapConfigStore, HashMapSecretStore, NoopHttpClient, StubBackend, StubHttpClient, + build_services_with_backend_and_http_client, build_services_with_config_secret_and_http_client, +}; +use crate::request_signing::RequestSigner; + +fn config(profile: &str, profile_config: Value) -> AuctionPlanConfig { + config_with_endpoint( + profile, + profile_config, + "https://exchange.example.test/openrtb", + ) +} + +fn config_with_endpoint(profile: &str, profile_config: Value, endpoint: &str) -> AuctionPlanConfig { + let provider_id = ProviderId::from_str("fictional-provider").expect("should parse provider ID"); + let prebid_server = profile == "prebid-server"; + AuctionPlanConfig { + timeout_ms: 321, + providers: BTreeMap::from([( + provider_id.clone(), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: profile.to_string(), + endpoint: endpoint.to_string(), + timeout_ms: Some(321), + routing: if prebid_server { + RoutingMode::Explicit + } else { + RoutingMode::AllEligible + }, + notifications: NotificationConfig::default(), + profile_config, + }, + )]), + bidders: if prebid_server { + BTreeMap::from([( + BidderId::from_str("exampleBidder").expect("should parse bidder ID"), + BidderRouteConfig { + provider: provider_id, + }, + )]) + } else { + 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("should have consent context") + .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 empty_overridden = canonical_parity_auction_request(); + empty_overridden.slots[0].bidders = HashMap::from([( + "trustedServer".to_string(), + json!({"zone":"zone-a","bidderParams":{"exampleBidder":{}}}), + )]); + let routed = route_auction(empty_overridden, &inbound, &plan, None); + let built = match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build request after populating empty params") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain overridden impression"), + }; + let value = serde_json::to_value(built).expect("should serialize overridden request"); + assert_eq!( + value["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], + json!({"generic":1,"ordered":2,"shared":"rule-two","zone":2}), + "should allow profile overrides to populate empty browser params" + ); + + 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_pairs_each_impression_with_its_routed_slot_params() { + let mut raw = config("prebid-server", json!({})); + 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 request = canonical_parity_auction_request(); + request.slots[0].id = "first-slot".to_string(); + request.slots[0].bidders = HashMap::from([( + "trustedServer".to_string(), + json!({"bidderParams":{"exampleBidder":{"placement":"first"}}}), + )]); + let mut second_slot = request.slots[0].clone(); + second_slot.id = "second-slot".to_string(); + second_slot.bidders = HashMap::from([( + "trustedServer".to_string(), + json!({"bidderParams":{"exampleBidder":{"placement":"second"}}}), + )]); + request.slots.push(second_slot); + 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 impressions"), + }; + let value = serde_json::to_value(built).expect("should serialize request"); + + assert_eq!(value["imp"][0]["id"], "first-slot"); + assert_eq!( + value["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], + json!({"placement":"first"}) + ); + assert_eq!(value["imp"][1]["id"], "second-slot"); + assert_eq!( + value["imp"][1]["ext"]["prebid"]["bidder"]["exampleBidder"], + json!({"placement":"second"}) + ); +} + +#[test] +fn pbs_empty_params_without_matching_override_fall_back_to_stored_request() { + let mut raw = config("prebid-server", json!({})); + 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 request = canonical_parity_auction_request(); + request.slots[0].bidders = HashMap::from([( + "trustedServer".to_string(), + json!({"bidderParams":{"exampleBidder":{}}}), + )]); + 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 stored request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should retain stored impression"), + }; + let value = serde_json::to_value(built).expect("should serialize stored request"); + assert_eq!( + value["imp"][0]["ext"]["prebid"]["storedrequest"]["id"], + "fictional-slot" + ); + assert!(value["imp"][0]["ext"]["prebid"].get("bidder").is_none()); +} + +#[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":{"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":{"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_with_formats( + formats: Vec, +) -> (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 mut auction_request = canonical_parity_auction_request(); + auction_request.slots[0].formats = formats; + let routed = route_auction(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) +} + +fn standard_fixture() -> (AuctionPlan, RoutedAuction, OpenRtbRequest) { + standard_fixture_with_formats(vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }]) +} + +#[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_currency_accepts_omitted_and_usd_but_rejects_other_or_malformed_values() { + let (_plan, routed, _request) = standard_fixture(); + let bid = json!({"seatbid": [{"seat": "seat", "bid": [ + {"id":"good","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250} + ]}]}); + + for currency in [None, Some(json!("USD")), Some(json!("usd"))] { + let mut value = bid.clone(); + if let Some(currency) = currency { + value["cur"] = currency; + } + let response = + extract_standard_response("fictional-provider", &routed.inputs()[0], &value, 0); + assert_eq!( + response.status, + BidStatus::Success, + "should accept omitted or USD currency" + ); + assert_eq!(response.bids[0].currency, "USD"); + } + + let mut eur = bid.clone(); + eur["cur"] = json!("EUR"); + let response = extract_standard_response("fictional-provider", &routed.inputs()[0], &eur, 0); + assert_eq!(response.status, BidStatus::NoBid); + assert_eq!(response.metadata["unsupported_currency"], "EUR"); + + let mut malformed = bid; + malformed["cur"] = json!(["USD"]); + let response = + extract_standard_response("fictional-provider", &routed.inputs()[0], &malformed, 0); + assert_eq!(response.status, BidStatus::Error); + assert_eq!(response.metadata["error_type"], "parse_response"); +} + +#[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")); + assert_eq!( + response.metadata["response_admission"]["rejected_bid_count"], + 2 + ); + assert_eq!( + response.metadata["response_admission"]["rejection_reasons"]["unrequested_impression"], + 1 + ); + assert_eq!( + response.metadata["response_admission"]["rejection_reasons"]["dimension_mismatch"], + 1 + ); +} + +#[test] +fn standard_response_infers_only_unambiguous_missing_dimensions() { + let (_plan, routed, _request) = standard_fixture(); + let inferred = extract_standard_response( + "fictional-provider", + &routed.inputs()[0], + &json!({"seatbid": [{"bid": [ + {"id":"inferred","impid":"fictional-slot","price":1.0,"adm":"ok"} + ]}]}), + 0, + ); + assert_eq!(inferred.status, BidStatus::Success); + assert_eq!(inferred.bids[0].width, 300); + assert_eq!(inferred.bids[0].height, 250); + + let formats = vec![ + AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }, + AdFormat { + media_type: MediaType::Banner, + width: 320, + height: 50, + }, + ]; + let (_plan, routed, _request) = standard_fixture_with_formats(formats); + let ambiguous = extract_standard_response( + "fictional-provider", + &routed.inputs()[0], + &json!({"seatbid": [{"bid": [ + {"id":"ambiguous","impid":"fictional-slot","price":1.0,"adm":"ok"} + ]}]}), + 0, + ); + assert_eq!(ambiguous.status, BidStatus::NoBid); + assert_eq!( + ambiguous.metadata["response_admission"]["rejected_bid_count"], + 1 + ); + assert_eq!( + ambiguous.metadata["response_admission"]["rejection_reasons"]["ambiguous_dimensions"], + 1 + ); +} + +#[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); + assert_eq!(response.metadata["error_type"], "parse_response"); + + 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["error_type"], "http_status"); + 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 prebid_endpoint_normalization_reaches_generic_execution_and_preserves_custom_paths() { + futures::executor::block_on(async { + for (configured_endpoint, expected_endpoint) in [ + ( + "https://pbs.example", + "https://pbs.example/openrtb2/auction", + ), + ("https://pbs.example/bid", "https://pbs.example/bid"), + ] { + let plan = AuctionPlan::compile(config_with_endpoint( + "prebid-server", + json!({}), + configured_endpoint, + )) + .expect("should compile Prebid Server endpoint"); + 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 provider_plan = plan.providers()[0].clone(); + let provider = GenericOpenRtbProvider::new(provider_plan.clone()); + let client = Arc::new(StubHttpClient::new()); + client.push_response(204, Vec::new()); + let services = build_services_with_backend_and_http_client( + Arc::new(StubBackend), + Arc::clone(&client) as Arc, + ); + let mut reserved_backend_names = HashSet::new(); + + let outcome = provider + .request_bids_routed( + &routed.inputs()[0], + &routed, + 321, + 321, + None, + &services, + &mut reserved_backend_names, + ) + .await + .expect("should launch one Prebid Server request"); + let ProviderRequestOutcome::Pending { request, .. } = outcome else { + panic!("should launch a pending Prebid Server request"); + }; + let selected = services + .http_client() + .select(vec![request]) + .await + .expect("should select one Prebid Server response"); + let response = selected + .ready + .expect("should receive the Prebid Server response"); + + assert_eq!(response.response.status(), http::StatusCode::NO_CONTENT); + assert_eq!(client.recorded_backend_names(), vec!["stub-backend"]); + assert_eq!(client.recorded_request_methods(), vec!["POST"]); + assert_eq!(client.recorded_request_uris(), vec![expected_endpoint]); + assert_eq!(provider_plan.backend_spec().host, "pbs.example"); + let body: Value = serde_json::from_slice(&client.recorded_request_bodies()[0]) + .expect("should parse recorded Prebid Server body"); + assert!( + !body + .as_object() + .expect("should serialize an object") + .is_empty() + ); + } + }); +} + +#[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); + assert_eq!(response.metadata["error_type"], "parse_response"); +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 728cc1efe..5522df127 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,10 @@ struct ProviderLaunchState { } /// Outcome of attempting to dispatch split-phase auction provider requests. +#[allow( + clippy::large_enum_variant, + reason = "Dispatched carries in-flight requests while failure preserves provider responses" +)] pub enum DispatchAuctionOutcome { /// No provider request was started and no provider failure was observed. NotStarted, @@ -53,6 +69,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 +98,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 +126,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(), } } } @@ -182,6 +215,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, @@ -192,6 +232,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(); @@ -203,22 +305,538 @@ 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>, } +/// 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 { + plan, + providers, + mediator, + } + } + + pub(crate) fn provider_count(&self) -> usize { + self.providers.len() + } + + pub(crate) fn mediator(&self) -> Option<&Arc> { + self.mediator.as_ref() + } + + /// Route and execute config-first bidder providers in deterministic order. + pub(crate) async fn run_auction( + &self, + 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 self.providers.len() > 1 && !context.services.http_client().supports_concurrent_fanout() + { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "{} auction providers configured, but this platform's HTTP client does not support concurrent fanout", + self.providers.len() + ), + })); + } + + // 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() + .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()); + if transport_timeout_ms == 0 { + responses.push(provider_timeout_response(provider.provider_name(), 0)); + continue; + } + 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); + let mut transport_failures = launches + .drain() + .map(|(_, state)| { + provider_transport_failed_response( + state.provider.provider_name(), + state.started_at.elapsed().as_millis() as u64, + ) + }) + .collect::>(); + transport_failures.sort_by(|left, right| left.provider.cmp(&right.provider)); + responses.extend(transport_failures); + 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()); + if transport_timeout_ms == 0 { + log::warn!( + "Planned mediator transport budget canonicalized to zero; using local ranking" + ); + let winning_bids = local_winners(); + return Ok(OrchestrationResult { + provider_responses: responses, + mediator_response: None, + winning_bids, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: routing_metadata(routed.diagnostics().unroutable_bidder_count()), + }); + } + 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(); + // lgtm[rust/cleartext-logging] + // This logs only a bounded routing count, never request data or secrets. + 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 new orchestrator with the given configuration. + /// Create a legacy orchestrator for parity tests. + #[cfg(test)] #[must_use] - pub fn new(config: AuctionConfig) -> Self { + 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(), } } - /// Register an auction provider. - pub fn register_provider(&mut self, provider: Arc) { + /// 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) + } + + /// 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); @@ -227,61 +845,44 @@ impl AuctionOrchestrator { /// Get the number of registered providers. #[must_use] pub fn provider_count(&self) -> usize { - self.providers.len() + self.planned_providers.len() } - /// Validate that every configured provider name has an enabled provider integration. - pub(crate) fn validate_configured_provider_names( + async fn run_planned_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> { + match self.dispatch_auction(request, context).await { + DispatchAuctionOutcome::Dispatched(dispatched) => Ok(self + .collect_dispatched_auction(dispatched, context.services, context) + .await), + DispatchAuctionOutcome::DispatchFailed { + fatal_admission_error, + .. + } => { + if let Some(error) = fatal_admission_error { + return Err(error.change_context(TrustedServerError::Auction { + message: "Planned auction admission failed".to_string(), + })); + } + Err(Report::new(TrustedServerError::Auction { + message: "All eligible planned provider requests failed to launch".to_string(), + })) } - } - - if let Some(mediator_name) = &self.config.mediator - && configured_providers.contains(mediator_name.as_str()) - { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Auction mediator `{mediator_name}` is also listed in [auction].providers; a provider may not mediate its own auction" - ), - })); - } - - for provider_name in self - .config - .providers - .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" - ), - })); + 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(), + })) + } } } - - Ok(()) } - /// 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) + /// Execute an auction through the compiled plan. /// /// # Errors /// @@ -292,9 +893,20 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + if !self.enabled { + return Ok(OrchestrationResult::no_bid()); + } + #[cfg(not(test))] + return self.run_planned_auction(request, context).await; + #[cfg(test)] + if self.plan_backed { + return self.run_planned_auction(request, context).await; + } + #[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", @@ -307,11 +919,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 @@ -319,6 +933,7 @@ impl AuctionOrchestrator { } /// Run auction with parallel bidding + mediation. + #[cfg(test)] /// /// Flow: /// 1. Run all bidders in parallel @@ -368,6 +983,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, }; @@ -395,12 +1011,30 @@ impl AuctionOrchestrator { mediator.provider_name() ), })?; - - mediator - .parse_response_with_context_and_state( - platform_resp, - start_time.elapsed().as_millis() as u64, - request, + 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, + response_time_ms, + request, &mediator_context, parse_state.as_deref(), ) @@ -449,6 +1083,7 @@ impl AuctionOrchestrator { } /// Run auction with only parallel bidding (no mediation). + #[cfg(test)] async fn run_parallel_only( &self, request: &AuctionRequest, @@ -468,6 +1103,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. @@ -476,7 +1112,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 { @@ -515,10 +1156,12 @@ 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 => { + // lgtm[rust/cleartext-logging] + // This logs a configured provider identifier, not request data or secrets. log::warn!("Provider '{}' not registered, skipping", provider_name); continue; } @@ -568,10 +1211,13 @@ 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, }; + // lgtm[rust/cleartext-logging] + // This logs a configured provider identifier and timeout, not request data or secrets. log::info!( "Launching bid request to '{}' with a {}ms budget", provider.provider_name(), @@ -671,7 +1317,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() @@ -715,10 +1361,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, }; @@ -796,16 +1452,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() { @@ -920,6 +1570,7 @@ impl AuctionOrchestrator { } /// Get a provider by name. + #[cfg(test)] fn get_provider( &self, name: &str, @@ -936,6 +1587,254 @@ 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(); + // A zero request budget is terminal before signing admission. In the + // split path, signer initialization would otherwise read config and + // secret stores even though every provider is materialized as timeout. + let signer_result = if context.timeout_ms == 0 { + Ok(None) + } else { + plan.signing_enabled() + .then(|| RequestSigner::from_services(context.services)) + .transpose() + }; + let signer = match signer_result { + 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; + let mut launch_failure_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 { + launch_failure_count += 1; + 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()); + if transport_timeout_ms == 0 { + completed_responses.push(provider_timeout_response(provider.provider_name(), 0)); + continue; + } + 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 { + launch_failure_count += 1; + 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(_) => { + launch_failure_count += 1; + 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() + ); + launch_failure_count += 1; + 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 { + if launch_failure_count > 0 && launch_failure_count == routed.inputs().len() { + for response in &mut completed_responses { + if let Some(&count) = + planned_unused_bidder_params.get(response.provider.as_str()) + { + *response = materialize_planned_response(response.clone(), count); + } + } + completed_responses.sort_by_key(|response| { + planned_provider_order + .get(response.provider.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + return DispatchAuctionOutcome::DispatchFailed { + request: request.clone(), + provider_responses: completed_responses, + fatal_admission_error: None, + metadata: routing_metadata(planned_unroutable_bidder_count), + elapsed_ms: auction_start.elapsed().as_millis() as u64, + }; + } + if routed.inputs().is_empty() && 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), + // Planned providers carry their typed parse state, so collection + // does not need to retain the inbound client request. Keep the + // explicit request boundary empty across the origin wait. + provider_request_context: Box::new(Request::new(EdgeBody::empty())), + 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 @@ -953,7 +1852,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; } @@ -963,6 +1875,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!( @@ -976,13 +1889,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] @@ -1009,6 +1935,8 @@ impl AuctionOrchestrator { // Match the synchronous path's strict deadline semantics: do not // invoke even an immediate provider after the budget reaches zero. if effective_timeout == 0 { + // lgtm[rust/cleartext-logging] + // This logs a configured provider identifier and timeout, not request data or secrets. log::warn!( "Auction timeout ({}ms) exhausted before launching '{}' — skipping", context.timeout_ms, @@ -1038,6 +1966,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, }; @@ -1081,6 +2010,8 @@ impl AuctionOrchestrator { )); continue; } + // lgtm[rust/cleartext-logging] + // This logs configured provider and backend identifiers plus a timeout, not request data or secrets. log::info!( "Dispatching bid request to '{}' (backend: {}, budget: {}ms)", provider.provider_name(), @@ -1125,11 +2056,15 @@ 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, } }; } + // lgtm[rust/cleartext-logging] + // This logs bounded request counts and a timeout, not request data or secrets. log::info!( "Dispatched {} SSP request(s) with {} immediate response(s) (timeout: {}ms)", pending_requests.len(), @@ -1140,12 +2075,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(), }) } @@ -1168,12 +2107,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!( @@ -1185,6 +2128,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 @@ -1197,6 +2141,30 @@ impl AuctionOrchestrator { Ok(r) => r, Err(e) => { log::warn!("select() failed during auction collection: {:?}", e); + // An outer select failure means the platform could not poll + // any outstanding handle. Attribute every tracked launch as + // a transport failure rather than later relabeling it as a + // timeout. Drain through a sorted buffer because HashMap + // iteration order is intentionally nondeterministic. + let mut transport_failures = backend_to_provider + .drain() + .map(|(_, state)| { + let response_time_ms = state.started_at.elapsed().as_millis() as u64; + provider_transport_failed_response( + &state.provider_name, + response_time_ms, + ) + }) + .chain(planned_backend_to_provider.drain().map(|(_, state)| { + let response_time_ms = state.started_at.elapsed().as_millis() as u64; + provider_transport_failed_response( + state.provider.provider_name(), + response_time_ms, + ) + })) + .collect::>(); + transport_failures.sort_by(|left, right| left.provider.cmp(&right.provider)); + responses.extend(transport_failures); break; } }; @@ -1214,10 +2182,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, }; @@ -1232,28 +2208,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!( @@ -1278,6 +2265,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): {:?}", @@ -1315,79 +2314,138 @@ 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()); + if transport_timeout_ms == 0 { + log::warn!( + "Mediator '{}' transport budget canonicalized to zero — 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: routing_metadata(planned_unroutable_bidder_count), + }; + } + let mediator_start = Instant::now(); + // lgtm[rust/cleartext-logging] + // This logs a configured mediator identifier and timeout values, not request data or secrets. + 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(), @@ -1403,24 +2461,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| { @@ -1436,16 +2496,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)) } } @@ -1458,14 +2511,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 } } @@ -1485,6 +2538,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> { @@ -1510,32 +2573,638 @@ 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, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, 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_LAUNCH_FAILED, ERROR_TYPE_TIMEOUT, ERROR_TYPE_TRANSPORT, OrchestrationResult, + }; + + 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(), + }), + } + } + + 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::Explicit, + notifications: notifications.clone(), + profile_config: profile_config.clone(), + }, + ) + }) + .collect(), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, + } + } + + 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, + } + } + + 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(), + } + } + + fn planned_prebid_request() -> AuctionRequest { + let mut request = planned_request(); + request.slots[0] + .bidders + .insert("trustedServer".to_string(), serde_json::json!({})); + request + } + + #[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_planned_launch_failures_error_direct_and_surface_split_failure() { + let plan = Arc::new( + AuctionPlan::compile(planned_config( + &[("launch-fail", RoutingMode::AllEligible)], + false, + )) + .expect("should compile launch-failure plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + backend.fail_ensure_for("launch-fail"); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + 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 error = orchestrator + .run_auction(&request, &context) + .await + .expect_err("all planned launch failures should fail direct execution"); + assert!( + error + .to_string() + .contains("All eligible planned provider requests failed to launch"), + "should distinguish launch failure from an ordinary no-bid: {error:?}" + ); + + let DispatchAuctionOutcome::DispatchFailed { + provider_responses, + fatal_admission_error, + .. + } = orchestrator.dispatch_auction(&request, &context).await + else { + panic!("all planned launch failures should surface a split dispatch failure"); + }; + assert!(fatal_admission_error.is_none()); + assert_eq!(provider_responses.len(), 1); + assert_eq!(provider_responses[0].provider, "launch-fail"); + assert_eq!( + provider_responses[0].metadata["error_type"], + ERROR_TYPE_LAUNCH_FAILED + ); + 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 ZeroCanonicalBackend { + predicted: AtomicUsize, + ensured: AtomicUsize, + } + + impl ZeroCanonicalBackend { + fn new() -> Self { + Self { + predicted: AtomicUsize::new(0), + ensured: AtomicUsize::new(0), + } + } + } + + impl PlatformBackend for ZeroCanonicalBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Fastly + } + + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + self.predicted.fetch_add(1, Ordering::Relaxed); + Ok("zero-canonical-backend".to_string()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + self.ensured.fetch_add(1, Ordering::Relaxed); + Ok("zero-canonical-backend".to_string()) + } + + fn canonicalize_transport_timeout_ms( + &self, + _remaining_ms: u32, + _configured_ms: u32, + ) -> u32 { + 0 + } + } + + 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 OuterSelectErrorHttpClient { + inner: StubHttpClient, + selected_pending: AtomicUsize, + } + + impl OuterSelectErrorHttpClient { + fn new() -> Self { + Self { + inner: StubHttpClient::new(), + selected_pending: AtomicUsize::new(0), + } + } + + fn push_response(&self, status: u16, body: Vec) { + self.inner.push_response(status, body); + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for OuterSelectErrorHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send(request).await + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send_async(request).await + } + + async fn select( + &self, + pending_requests: Vec, + ) -> Result> { + self.selected_pending + .store(pending_requests.len(), Ordering::Relaxed); + Err(Report::new(PlatformError::HttpClient)) + } + } + + 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 @@ -1548,7 +3217,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for StubAuctionProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { self.name } @@ -1616,17 +3285,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 } @@ -1635,16 +3301,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 @@ -1653,7 +3315,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) } @@ -1665,20 +3327,180 @@ 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()) + } + } + + type RecordedMediatorBudgets = Arc>>; + + struct DeadlineRecordingMediator { + launches: Arc, + budgets: Option, + } + + struct PendingDeadlineMediator; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for PendingDeadlineMediator { + fn provider_name(&self) -> &str { + "pending-deadline-mediator" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let request = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/mediate") + .body(edgezero_core::body::Body::empty()) + .expect("should build pending mediator request"), + "pending-mediator-backend", + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "pending mediator launch failed".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + self.provider_name(), + vec![auction_bid("mediated", 9.0)], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 1_000 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("pending-mediator-backend".to_string()) + } + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for DeadlineRecordingMediator { + fn provider_name(&self) -> &str { + "deadline-mediator" + } + + async fn request_bids( + &self, + _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, + ))) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("immediate mediator response should not be parsed"); + } + + fn timeout_ms(&self) -> u32 { + 1_000 + } + } + + struct RecordingTimeoutProvider { + 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) -> &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()) } } @@ -1691,7 +3513,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for DivergentBackendProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { self.name } @@ -1746,6 +3568,10 @@ mod tests { } impl PlatformBackend for CanonicalTimeoutBackend { + fn naming_policy(&self) -> crate::platform::BackendNamingPolicy { + crate::platform::BackendNamingPolicy::Axum + } + fn predict_name( &self, _spec: &PlatformBackendSpec, @@ -1810,6 +3636,7 @@ mod tests { .then(|| "
ordinary
".to_string()), adomain: None, bidder: bidder.to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -1833,6 +3660,7 @@ mod tests { creative: Some("
ad
".to_string()), adomain: None, bidder: "mediator".to_string(), + returned_seat: None, width: 728, height: 90, nurl: nurl.clone(), @@ -1850,7 +3678,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for CacheRestoringMediator { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "mediator" } @@ -1919,7 +3747,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" } @@ -1965,7 +3793,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() @@ -1988,6 +3816,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -2021,7 +3850,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() @@ -2039,6 +3868,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services: &services, }; @@ -2076,90 +3906,350 @@ 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(), - } - } - - 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) -> &'static str { - "immediate" - } + 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, + }; - async fn request_bids( - &self, - _request: &AuctionRequest, - _context: &AuctionContext<'_>, - ) -> Result> { - Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( - "immediate", - 0, - ))) + 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") } + } - async fn parse_response( - &self, - _response: PlatformResponse, - _response_time_ms: u64, - ) -> Result> { - panic!("immediate response should not be parsed"); + #[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" + ); } + } - fn timeout_ms(&self) -> u32 { - 2000 + #[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()); } } - struct LaunchFailingProvider; - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for LaunchFailingProvider { - fn provider_name(&self) -> &'static str { + 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, + ))) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("immediate response should not be parsed"); + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + } + + struct LaunchFailingProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for LaunchFailingProvider { + fn provider_name(&self) -> &str { "launch-failing" } @@ -2201,6 +4291,7 @@ mod tests { settings, request, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, } @@ -2210,7 +4301,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() }; @@ -2235,7 +4326,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() }; @@ -2266,7 +4357,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() }; @@ -2424,6 +4515,7 @@ mod tests { creative: Some("
Ad
".to_string()), adomain: None, bidder: "test-bidder".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -2447,6 +4539,7 @@ mod tests { creative: Some("
Ad
".to_string()), adomain: None, bidder: "test-bidder".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -2492,7 +4585,8 @@ mod tests { enabled: true, sanitize_creatives: 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(), @@ -2523,7 +4617,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() }; @@ -2553,41 +4647,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() }; @@ -2702,7 +4767,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() }); @@ -2746,7 +4811,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() }); @@ -2789,7 +4854,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() @@ -2839,7 +4904,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() @@ -2893,6 +4958,64 @@ mod tests { }); } + #[test] + fn planned_collect_skips_mediator_with_zero_canonical_transport_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), 0); + assert!( + budgets + .lock() + .expect("should lock mediator budgets") + .is_empty() + ); + assert_eq!(calls.lock().expect("should lock calls").len(), 1); + assert!(result.mediator_response.is_none()); + }); + } + #[test] fn dispatched_resolved_backend_name_diverging_from_prediction_still_correlates() { futures::executor::block_on(async { @@ -2901,7 +5024,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() }); @@ -2939,7 +5062,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() }); @@ -2994,7 +5117,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() @@ -3020,6 +5143,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -3061,46 +5185,205 @@ mod tests { }); } - #[test] - fn dispatched_collection_reuses_provider_launch_context() { - futures::executor::block_on(async { - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); - let config = AuctionConfig { - enabled: true, - providers: vec!["provider-a".to_string()], - timeout_ms: 750, - mediator: None, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - let request = create_test_auction_request(); - let settings = create_test_settings(); - let downstream = http::Request::builder() - .uri("https://publisher.example/article") - .header(http::header::REFERER, "https://referrer.example/source") - .body(edgezero_core::body::Body::empty()) - .expect("should build downstream request"); - let dispatch_context = AuctionContext { - settings: &settings, - request: &downstream, - timeout_ms: 750, - provider_responses: None, - services: &services, - }; - let dispatched = match orchestrator - .dispatch_auction(&request, &dispatch_context) - .await - { - DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch provider request"), - }; - let placeholder = http::Request::builder() + #[tokio::test] + async fn harness_outer_select_error_materializes_all_launches_as_transport_failures() { + let http = Arc::new(OuterSelectErrorHttpClient::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 = Arc::new( + AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile planned auction"), + ); + let harness = 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 = harness + .run_auction(&request, &context) + .await + .expect("should collect a harness auction after select failure"); + + assert_eq!( + result.provider_responses.len(), + 1, + "should report one provider response" + ); + assert_eq!( + result.provider_responses[0].status, + BidStatus::Error, + "outer select failure should report an error" + ); + assert_eq!( + result.provider_responses[0].metadata["error_type"], ERROR_TYPE_TRANSPORT, + "harness should match production transport classification" + ); + } + + #[tokio::test] + async fn planned_dispatch_does_not_snapshot_inbound_headers() { + 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 = Arc::new( + AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile planned auction"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::builder() + .header(http::header::AUTHORIZATION, "Bearer should-not-be-retained") + .header(http::header::COOKIE, "session=should-not-be-retained") + .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 DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("should dispatch planned provider"); + }; + + assert!( + dispatched.provider_request_context.headers().is_empty(), + "planned dispatch should retain an empty request context" + ); + } + + #[tokio::test] + async fn outer_select_error_materializes_all_planned_launches_as_transport_failures() { + let http = Arc::new(OuterSelectErrorHttpClient::new()); + http.push_response(204, Vec::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 = Arc::new( + AuctionPlan::compile(planned_config( + &[ + ("provider-b", RoutingMode::AllEligible), + ("provider-a", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"), + ); + let orchestrator = AuctionOrchestrator::from_plan(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 DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("should dispatch both planned providers"); + }; + tokio::time::sleep(Duration::from_millis(5)).await; + let result = orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await; + + assert_eq!(http.selected_pending.load(Ordering::Relaxed), 2); + assert_eq!( + result + .provider_responses + .iter() + .map(|response| response.provider.as_str()) + .collect::>(), + vec!["provider-a", "provider-b"], + "outer select errors should retain deterministic plan order" + ); + for response in &result.provider_responses { + assert_eq!(response.status, BidStatus::Error); + assert_eq!(response.metadata["error_type"], "transport"); + assert!( + response.response_time_ms >= 5, + "transport failure should preserve launch elapsed time" + ); + } + } + + #[test] + fn dispatched_collection_reuses_provider_launch_context() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["provider-a"]), + timeout_ms: 750, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + })); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let downstream = http::Request::builder() + .uri("https://publisher.example/article") + .header(http::header::REFERER, "https://referrer.example/source") + .body(edgezero_core::body::Body::empty()) + .expect("should build downstream request"); + let dispatch_context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 750, + transport_timeout_ms: 750, + provider_responses: None, + services: &services, + }; + let dispatched = match orchestrator + .dispatch_auction(&request, &dispatch_context) + .await + { + DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, + _ => panic!("should dispatch provider request"), + }; + let placeholder = http::Request::builder() .uri("https://placeholder.invalid/") .body(edgezero_core::body::Body::empty()) .expect("should build placeholder request"); @@ -3108,6 +5391,7 @@ mod tests { settings: &settings, request: &placeholder, timeout_ms: 750, + transport_timeout_ms: 750, provider_responses: None, services: &services, }; @@ -3147,7 +5431,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() @@ -3173,6 +5457,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -3212,7 +5497,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() @@ -3238,6 +5523,7 @@ mod tests { settings: &settings, request: &req, timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, services, }; @@ -3257,6 +5543,2118 @@ 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_prebid_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, None), + (400, Vec::new(), BidStatus::Error, None, Some("http_status")), + ( + 200, + b"not-json".to_vec(), + BidStatus::Error, + Some("unexpected_response_shape"), + Some("parse_response"), + ), + ( + 200, + b"[]".to_vec(), + BidStatus::Error, + Some("unexpected_response_shape"), + Some("parse_response"), + ), + ( + 200, + br#"{"contextual":true}"#.to_vec(), + BidStatus::Error, + Some("unexpected_response_shape"), + Some("parse_response"), + ), + ( + 200, + br#"{"cur":"EUR","seatbid":[]}"#.to_vec(), + BidStatus::NoBid, + Some("unsupported_currency"), + None, + ), + ]; + for (status, body, expected, reason, error_type) 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}" + ); + } + if let Some(error_type) = error_type { + assert_eq!(parsed.metadata["error_type"], error_type, "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_prebid_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}"); + if expected == BidStatus::Error { + let expected_error_type = if (200..300).contains(&status) { + "parse_response" + } else { + "http_status" + }; + assert_eq!(parsed.metadata["error_type"], expected_error_type); + } + 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_prebid_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 zero_canonical_timeout_skips_plan_backed_direct_and_split_launches() { + for split in [false, true] { + let http = Arc::new(StubHttpClient::new()); + let backend = Arc::new(ZeroCanonicalBackend::new()); + 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", RoutingMode::AllEligible)], false); + config.mediator = Some("adserver_mock".to_string()); + let plan = AuctionPlan::compile(config).expect("should compile planned auction"); + let mediator_predicted = Arc::new(Mutex::new(Vec::new())); + let mediator_requested = Arc::new(Mutex::new(Vec::new())); + let mediator = Arc::new(recording_provider( + "adserver_mock", + "mediator-backend", + 777, + &mediator_predicted, + &mediator_requested, + )); + let orchestrator = AuctionOrchestrator::from_plan(Arc::new(plan), Some(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 = if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("zero canonical timeout should materialize a split response"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("should materialize direct timeout response") + }; + + assert_eq!(result.provider_responses.len(), 1); + assert_eq!( + result.provider_responses[0].metadata["error_type"], + "timeout" + ); + assert_eq!(backend.predicted.load(Ordering::Relaxed), 0); + assert_eq!(backend.ensured.load(Ordering::Relaxed), 0); + assert!(http.recorded_backend_names().is_empty()); + assert!( + mediator_predicted + .lock() + .expect("should lock mediator predictions") + .is_empty() + ); + assert!( + mediator_requested + .lock() + .expect("should lock mediator requests") + .is_empty() + ); + } + } + + #[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["error_type"], "parse_response"); + 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 from_plan_split_zero_budget_does_no_signer_backend_or_network_work() { + let config_store = Arc::new(CountingConfigStore { + reads: AtomicUsize::new(0), + current_kid: "unused-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, [13_u8; 32]) + .into_bytes(), + }); + 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::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 = Arc::new( + AuctionPlan::compile(planned_config( + &[("signed", RoutingMode::AllEligible)], + true, + )) + .expect("should compile signed plan"), + ); + let mediator_launches = Arc::new(AtomicUsize::new(0)); + let orchestrator = AuctionOrchestrator::from_plan( + 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 DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("zero budget should materialize a completed split dispatch"); + }; + let result = orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await; + + 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!(secret_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()); @@ -3308,6 +7706,7 @@ mod tests { creative: Some("
Ad
".to_string()), adomain: None, bidder: "aps".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -3352,6 +7751,7 @@ mod tests { creative: Some("
APS Ad
".to_string()), adomain: None, bidder: "aps".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -3391,6 +7791,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..df36fc09b --- /dev/null +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -0,0 +1,1372 @@ +//! 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 {value:?} 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, + timeout_ms: u32, + 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 registration.id == "prebid-server" && raw.routing == RoutingMode::AllEligible { + return Err(configuration_error(format!( + "provider `{id}` cannot use routing `all_eligible` with profile `prebid-server`; configure explicit bidder routes" + ))); + } + 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, + timeout_ms: config.timeout_ms, + 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 naming_policy = target.naming_policy(); + let backend_budget = naming_policy.auction_dynamic_backend_budget(); + let mut required_backend_names = 0_usize; + let mut predicted_names = BTreeMap::::new(); + for provider in &self.providers { + let reachable_timeout_ms = provider.timeout_ms.min(self.timeout_ms); + required_backend_names = required_backend_names + .saturating_add(naming_policy.transport_timeout_bucket_count(reachable_timeout_ms)); + if let Some(budget) = backend_budget + && required_backend_names > budget + { + return Err(configuration_error(format!( + "auction target `{}` requires up to {required_backend_names} dynamic provider backends, exceeding its auction budget of {budget}", + target_id.adapter_id(), + ))); + } + let spec = provider.backend_spec(); + let prediction = + 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`" + ))); + } + if profile_id == "prebid-server" { + normalize_prebid_server_endpoint(&mut endpoint); + } + Ok(CanonicalProviderEndpoint(endpoint)) +} + +fn normalize_prebid_server_endpoint(endpoint: &mut Url) { + match endpoint.path() { + "" | "/" => endpoint.set_path("/openrtb2/auction"), + "/openrtb2/auction/" => endpoint.set_path("/openrtb2/auction"), + _ => {} + } +} + +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 fastly_target_validation_canonicalizes_url_derived_ipv6_prediction() { + let mut ipv6_provider = provider("standard"); + ipv6_provider.endpoint = "https://[2001:db8::5]:8443/openrtb".to_string(); + ipv6_provider.timeout_ms = Some(750); + let plan = AuctionPlan::compile(config(BTreeMap::from([( + id("ipv6-provider"), + ipv6_provider, + )]))) + .expect("should compile IPv6 provider plan"); + + plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) + .expect("Fastly target validation should accept canonical IPv6 prediction"); + let bracketed_spec = plan.providers()[0].backend_spec(); + assert_eq!(bracketed_spec.host, "[2001:db8::5]"); + let mut bare_spec = bracketed_spec.clone(); + bare_spec.host = "2001:db8::5".to_string(); + let policy = crate::platform::BackendNamingPolicy::Fastly; + assert_eq!( + policy + .predict(&bracketed_spec) + .expect("should predict URL-derived bracketed IPv6 backend"), + policy + .predict(&bare_spec) + .expect("should predict runtime-normalized bare IPv6 backend"), + "startup target validation and Fastly runtime must hash the same backend spec" + ); + } + + #[test] + fn fastly_target_validation_reserves_dynamic_backends_outside_auction() { + let plan_with = |count: usize| { + let providers = (0..count) + .map(|index| { + let mut provider = provider("standard"); + provider.timeout_ms = Some(1000); + (id(&format!("provider-{index}")), provider) + }) + .collect(); + AuctionPlan::compile(config(providers)).expect("should compile provider plan") + }; + + plan_with(19) + .validate_for_target(crate::platform::AuctionTargetId::Fastly) + .expect("below-budget provider plan should validate"); + plan_with(20) + .validate_for_target(crate::platform::AuctionTargetId::Fastly) + .expect("at-budget provider plan should validate"); + let error = plan_with(21) + .validate_for_target(crate::platform::AuctionTargetId::Fastly) + .expect_err("over-budget provider plan should fail"); + assert!( + error + .to_string() + .contains("exceeding its auction budget of 160") + ); + } + + #[test] + fn fastly_backend_quota_uses_auction_timeout_as_reachable_bucket_ceiling() { + let providers = (0..21) + .map(|index| { + let mut provider = provider("standard"); + provider.timeout_ms = Some(1000); + (id(&format!("provider-{index}")), provider) + }) + .collect(); + let mut bounded = config(providers); + bounded.timeout_ms = 100; + let plan = AuctionPlan::compile(bounded).expect("should compile bounded provider plan"); + + assert!( + plan.providers() + .iter() + .all(|provider| provider.timeout_ms == 1000), + "quota validation must not rewrite configured provider timeouts" + ); + plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) + .expect("21 providers reach only the 50ms and 100ms Fastly buckets"); + } + + #[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, + timeout_ms: disabled.timeout_ms, + 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, + timeout_ms: compiled.timeout_ms, + 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)] { + let error = BidderId::from_str(invalid).expect_err("should reject invalid bidder ID"); + assert!( + error.to_string().contains(&format!("{invalid:?}")), + "should identify invalid bidder ID {invalid:?}: {error:?}" + ); + } + } + + #[test] + fn compiler_rejects_all_eligible_for_prebid_server_only() { + let mut prebid = provider("prebid-server"); + prebid.routing = RoutingMode::AllEligible; + let error = AuctionPlan::compile(config(BTreeMap::from([(id("pbs-main"), prebid)]))) + .expect_err("should reject all_eligible Prebid Server routing"); + let message = error.to_string(); + for expected in ["pbs-main", "all_eligible", "prebid-server"] { + assert!( + message.contains(expected), + "should identify provider, routing, and profile: {error:?}" + ); + } + + let mut standard = provider("standard"); + standard.routing = RoutingMode::AllEligible; + AuctionPlan::compile(config(BTreeMap::from([(id("standard-main"), standard)]))) + .expect("should retain all_eligible for non-Prebid profiles"); + } + + #[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 compiler_normalizes_only_prebid_server_origin_and_canonical_paths() { + for (configured, expected) in [ + ( + "https://pbs.example", + "https://pbs.example/openrtb2/auction", + ), + ( + "https://pbs.example/", + "https://pbs.example/openrtb2/auction", + ), + ( + "https://pbs.example/openrtb2/auction", + "https://pbs.example/openrtb2/auction", + ), + ( + "https://pbs.example/openrtb2/auction/", + "https://pbs.example/openrtb2/auction", + ), + ( + "https://pbs.example?region=example", + "https://pbs.example/openrtb2/auction?region=example", + ), + ("https://pbs.example/bid", "https://pbs.example/bid"), + ( + "https://pbs.example/custom/pbs", + "https://pbs.example/custom/pbs", + ), + ] { + let mut pbs = provider("prebid-server"); + pbs.endpoint = configured.to_string(); + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("pbs"), pbs)]))) + .expect("should compile Prebid Server endpoint"); + assert_eq!( + plan.providers()[0].endpoint.as_str(), + expected, + "{configured}" + ); + } + + let mut standard = provider("standard"); + standard.endpoint = "https://bid.example/".to_string(); + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("standard"), standard)]))) + .expect("should compile standard root endpoint"); + assert_eq!( + plan.providers()[0].endpoint.as_str(), + "https://bid.example/" + ); + + 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"}); + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))) + .expect("should compile APS endpoint"); + assert_eq!( + plan.providers()[0].endpoint.as_str(), + "https://aps.example/e/pb/bid" + ); + } + + #[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..51a91e69c --- /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>, +{ + T::deserialize(value) + .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..28f6dbd96 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,398 @@ 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) + .with_metadata("error_type", json!("parse_response")) + } + }; + 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) + .with_metadata("error_type", json!("parse_response")); + 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("error_type", json!("http_status")) + .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) + .with_metadata("error_type", json!("parse_response")); + 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..61b2302cc --- /dev/null +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -0,0 +1,1200 @@ +//! 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 || !value.is_object() { + 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}}))), + ), + ("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 envelope_preserves_empty_bidder_params_without_rejecting_valid_siblings() { + let normalized = normalize_envelope(&envelope(Some(json!({ + "alpha": {}, + "beta": {"placement": 42} + })))) + .expect("should preserve object-valued bidder params"); + + assert_eq!( + normalized + .bidder_params + .get(&BidderId::from_str("alpha").expect("should parse bidder")), + Some(&json!({})), + "should preserve empty params for profile overrides" + ); + assert_eq!( + normalized + .bidder_params + .get(&BidderId::from_str("beta").expect("should parse bidder")), + Some(&json!({"placement": 42})), + "should preserve valid sibling params" + ); + } + + #[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 af52b7ad2..caee91cf2 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -1,9 +1,13 @@ -//! Auction configuration types (separated to avoid circular deps in build.rs). +//! Auction configuration types shared by settings and auction planning. use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use validator::Validate; +pub use crate::auction::plan::{ + BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, +}; + /// Auction orchestration configuration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] @@ -31,22 +35,25 @@ pub struct AuctionConfig { /// Rewrite winning-bid creative HTML to first-party endpoints (applied /// after sanitization when [`Self::sanitize_creatives`] is enabled). /// - /// The default must stay omitted from serialized config blobs: older - /// [`AuctionConfig`] schemas reject unknown fields during binary rollback. - /// An explicit `false` remains serialized and requires restoring a - /// compatible blob before rolling back. + /// The default stays omitted from serialized config blobs to avoid adding + /// this field when it has no effect. Any rollback across schema versions + /// still requires restoring the matching old-schema blob with the old + /// binary. #[serde( default = "default_rewrite_creatives", skip_serializing_if = "is_default_rewrite_creatives" )] 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, @@ -74,7 +81,8 @@ impl Default for AuctionConfig { enabled: false, sanitize_creatives: default_sanitize_creatives(), 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(), @@ -95,7 +103,7 @@ fn default_rewrite_creatives() -> bool { true } -// This predicate preserves rollback compatibility by omitting the default field. +// Omit the default field when it has no effect on the serialized config. fn is_default_rewrite_creatives(value: &bool) -> bool { *value == default_rewrite_creatives() } @@ -112,15 +120,27 @@ fn default_allowed_context_keys() -> HashSet { HashSet::new() } -#[allow( - dead_code, - 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. @@ -222,4 +242,37 @@ mod tests { "should preserve an explicit sanitize opt-in" ); } + + #[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 f878489d6..0999c6f76 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,19 @@ 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)?; + let plan = crate::auction::compile_auction_plan(settings)?; + validate_enabled_integrations(settings, &plan)?; 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"); - } + plan: &crate::auction::AuctionPlan, +) -> Result<(), Report> { + validate_prebid(settings, plan)?; + validate_integration::(settings, "aps")?; + validate_integration::(settings, "adserver_mock")?; validate_integration::(settings, "testlight")?; validate_integration::(settings, "nextjs")?; validate_integration::(settings, "permutive")?; @@ -161,11 +153,19 @@ 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, + plan: &crate::auction::AuctionPlan, +) -> Result<(), Report> { + let Some(config) = settings.integration_config::("prebid")? + else { + return Ok(()); + }; + prebid::validate_browser_config_for_startup(&config, &settings.proxy.allowed_domains)?; + prebid::validate_browser_bidder_ownership(&config, plan) } fn validate_integration( @@ -180,32 +180,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())); @@ -217,7 +191,10 @@ fn report_to_validation_errors(report: &Report) -> Validatio #[cfg(test)] mod tests { + use std::collections::HashSet; + use super::*; + use crate::auction_config_types::{NotificationConfig, ProviderConfig, RoutingMode}; use crate::test_support::tests::crate_test_settings_str; #[derive(Debug, Deserialize)] @@ -267,6 +244,21 @@ formats = [{ width = 300, height = 250 }] settings } + fn insert_aps_provider(settings: &mut Settings, account_id: &str) { + settings.auction.providers.insert( + "aps-main".parse().expect("should parse APS provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example.com/e/pb/bid".to_string(), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({ "account_id": account_id }), + }, + ); + } + /// Source-controlled operator-facing config template. const EXAMPLE_TEMPLATE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -571,51 +563,27 @@ password = "production-admin-password-32-bytes" #[test] fn deploy_validation_rejects_blank_aps_account_id() { - // `deserialize_account_id` trims then rejects an empty result, so blank - // and whitespace-only ids fail at parse time. for (label, account_id) in [("empty", ""), ("whitespace-only", " ")] { let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "aps", - &serde_json::json!({ - "enabled": true, - "account_id": account_id, - "endpoint": "https://aps.example.com/e/pb/bid" - }), - ) - .expect("should insert APS config"); + insert_aps_provider(&mut settings, account_id); let err = validate_settings_for_deploy(&settings) - .expect_err("should reject blank APS account_id when enabled"); + .expect_err("should reject blank APS account_id"); assert!( - format!("{err:?}").contains("aps"), - "should mention the APS integration for {label} account_id: {err:?}" + format!("{err:?}").contains("account_id"), + "should mention the APS profile account_id for {label}: {err:?}" ); } } #[test] fn deploy_validation_normalizes_padded_aps_account_id() { - // Surrounding whitespace is normalized (trimmed) at deserialization, so - // a padded-but-otherwise-valid id deploys and reaches APS trimmed. let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "aps", - &serde_json::json!({ - "enabled": true, - "account_id": " example-account ", - "endpoint": "https://aps.example.com/e/pb/bid" - }), - ) - .expect("should insert APS config"); + insert_aps_provider(&mut settings, " example-account "); validate_settings_for_deploy(&settings) - .expect("should accept a padded-but-valid APS account_id (trimmed at deserialization)"); + .expect("should accept a padded APS profile account_id after trimming it"); } #[test] @@ -638,25 +606,10 @@ password = "production-admin-password-32-bytes" ); } - /// `enabled` defaults to `false` for APS, so a section that omits the flag - /// resolves to disabled and must not have its fields validated — otherwise - /// the documented template placeholder breaks existing configs on upgrade. + /// Integrations that default to disabled do not validate inactive fields. #[test] fn deploy_validation_skips_field_validation_for_integrations_with_omitted_enabled() { let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "aps", - &serde_json::json!({ - "pub_id": "your-aps-publisher-id", - "endpoint": "https://aps.example.com/e/dtb/bid" - }), - ) - .expect("should insert APS config"); - // `endpoint` parses as a plain string but would fail the `url` - // validator, so this section only survives if validation is skipped for - // integrations that resolve to disabled. settings .integrations .insert_config( @@ -684,6 +637,93 @@ 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_rejects_conflicting_prebid_browser_bidder_ownership() { + let mut settings = valid_settings(); + settings.auction.enabled = true; + settings.auction.providers = crate::auction::AuctionConfig::legacy_provider_map(&["pbs"]); + settings.auction.bidders.insert( + "exampleBidder" + .parse() + .expect("should parse server-side bidder"), + crate::auction::BidderRouteConfig { + provider: "pbs".parse().expect("should parse provider"), + }, + ); + let mut prebid = settings + .integration_config::("prebid") + .expect("should parse Prebid config") + .expect("should have enabled Prebid config"); + prebid.client_side_bidders = vec!["exampleBidder".to_string()]; + settings + .integrations + .insert_config("prebid", &prebid) + .expect("should replace Prebid config"); + + let error = validate_settings_for_deploy(&settings) + .expect_err("should reject conflicting browser bidder ownership"); + assert!(error.to_string().contains("exampleBidder")); + assert!( + error + .to_string() + .contains("both client-side and server-side") + ); + } + + #[test] + fn deploy_validation_accepts_dormant_prebid_browser_bidder_overlap() { + let mut settings = valid_settings(); + settings.auction.enabled = false; + settings.auction.providers = crate::auction::AuctionConfig::legacy_provider_map(&["pbs"]); + settings.auction.bidders.insert( + "exampleBidder" + .parse() + .expect("should parse server-side bidder"), + crate::auction::BidderRouteConfig { + provider: "pbs".parse().expect("should parse provider"), + }, + ); + let mut prebid = settings + .integration_config::("prebid") + .expect("should parse Prebid config") + .expect("should have enabled Prebid config"); + prebid.client_side_bidders = vec!["exampleBidder".to_string()]; + settings + .integrations + .insert_config("prebid", &prebid) + .expect("should replace Prebid config"); + + validate_settings_for_deploy(&settings) + .expect("disabled plan overlap should remain deployable"); + } + + #[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> = @@ -761,7 +801,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..fca06fad7 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -43,30 +43,12 @@ pub fn settings_from_config_blob( #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; + use crate::integrations::IntegrationRegistry; 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") @@ -78,6 +60,31 @@ mod tests { serde_json::to_string(&envelope).expect("should serialize envelope") } + fn settings_with_browser_bidder_overlap(auction_enabled: bool) -> Settings { + let mut settings = test_settings(); + settings.proxy.allowed_domains = vec!["*.example".to_string()]; + settings.auction.enabled = auction_enabled; + settings.auction.providers = crate::auction::AuctionConfig::legacy_provider_map(&["pbs"]); + settings.auction.bidders.insert( + "exampleBidder" + .parse() + .expect("should parse server-side bidder"), + crate::auction::BidderRouteConfig { + provider: "pbs".parse().expect("should parse provider"), + }, + ); + let mut prebid = settings + .integration_config::("prebid") + .expect("should parse Prebid config") + .expect("should have enabled Prebid config"); + prebid.client_side_bidders = vec!["exampleBidder".to_string()]; + settings + .integrations + .insert_config("prebid", &prebid) + .expect("should replace Prebid config"); + settings + } + #[test] fn payload_round_trips_through_blob_envelope() { let original = test_settings(); @@ -123,19 +130,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(); @@ -177,6 +171,42 @@ mod tests { ); } + #[test] + fn runtime_blob_rejects_enabled_browser_bidder_ownership_conflict() { + let original = settings_with_browser_bidder_overlap(true); + let reconstructed = settings_from_config_blob(&envelope_json(&original)) + .expect("should decode conflicting runtime blob before registry construction"); + let plan = Arc::new( + crate::auction::compile_auction_plan(&reconstructed) + .expect("should compile decoded enabled auction plan"), + ); + + let error = match IntegrationRegistry::with_plan(&reconstructed, plan) { + Ok(_) => panic!("runtime registry should reject enabled ownership conflict"), + Err(error) => error, + }; + assert!(error.to_string().contains("exampleBidder")); + assert!( + error + .to_string() + .contains("both client-side and server-side") + ); + } + + #[test] + fn runtime_blob_accepts_disabled_browser_bidder_ownership_overlap() { + let original = settings_with_browser_bidder_overlap(false); + let reconstructed = settings_from_config_blob(&envelope_json(&original)) + .expect("should decode dormant conflicting runtime blob"); + let plan = Arc::new( + crate::auction::compile_auction_plan(&reconstructed) + .expect("should compile decoded disabled auction plan"), + ); + + IntegrationRegistry::with_plan(&reconstructed, plan) + .expect("runtime registry should accept disabled ownership overlap"); + } + #[test] fn tampered_blob_hash_is_rejected() { let mut envelope: BlobEnvelope = diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index ab272e4da..2254c27d5 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -878,23 +878,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()); @@ -984,17 +977,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` @@ -1038,7 +1028,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 { @@ -1956,6 +1957,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 816f98167..62ecab229 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -1050,8 +1050,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); @@ -1147,7 +1153,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, @@ -1315,7 +1328,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 1d6527934..d4fbd3231 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -313,6 +313,7 @@ impl AdServerMockProvider { width, height, bidder: restored_bidder, + returned_seat: original.and_then(|bid| bid.returned_seat.clone()), adomain: bid["adomain"].as_array().map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) @@ -399,7 +400,7 @@ impl AdServerMockProvider { #[async_trait(?Send)] impl AuctionProvider for AdServerMockProvider { - fn provider_name(&self) -> &'static str { + fn provider_name(&self) -> &str { "adserver_mock" } @@ -466,14 +467,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!( @@ -535,12 +536,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!( @@ -638,6 +643,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, @@ -687,6 +693,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, @@ -713,6 +720,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()), @@ -796,6 +804,49 @@ mod tests { assert_eq!(bid.height, 90); } + #[test] + fn unmatched_mediator_seats_do_not_become_upstream_returned_seats() { + let provider = AdServerMockProvider::new(AdServerMockConfig::default()); + let mediation_response = json!({ + "seatbid": [ + { + "seat": "provider-instance", + "bid": [{ + "id": "bid-provider", + "impid": "slot-provider", + "price": 1.0, + "adm": "
Provider
", + "w": 300, + "h": 250, + "crid": "uncorrelated-provider-creative" + }] + }, + { + "seat": "unknown", + "bid": [{ + "id": "bid-unknown", + "impid": "slot-unknown", + "price": 2.0, + "adm": "
Unknown
", + "w": 728, + "h": 90, + "crid": "uncorrelated-unknown-creative" + }] + } + ] + }); + + let response = provider.parse_mediation_response(&mediation_response, 10, &BidIndex::new()); + + assert_eq!(response.bids.len(), 2); + assert_eq!(response.bids[0].bidder, "provider-instance"); + assert_eq!(response.bids[1].bidder, "unknown"); + assert!( + response.bids.iter().all(|bid| bid.returned_seat.is_none()), + "an unmatched mediator seat is provider correlation identity, not an upstream seat" + ); + } + #[test] fn parse_mediation_response_restores_original_bid_render_fields() { let provider = AdServerMockProvider::new(AdServerMockConfig::default()); @@ -834,6 +885,7 @@ mod tests { creative: Some("
Original Ad
".to_string()), adomain: Some(vec!["example.com".to_string()]), bidder: "mocktioneer".to_string(), + returned_seat: Some("upstream-seat".to_string()), width: 728, height: 90, nurl: Some("https://ssp.example/win".to_string()), @@ -906,6 +958,11 @@ mod tests { Some("/cache"), "should restore PBS cache path" ); + assert_eq!( + bid.returned_seat.as_deref(), + Some("upstream-seat"), + "should restore returned seat only from the matched original bid" + ); } #[test] @@ -942,6 +999,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, @@ -1103,6 +1161,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 581e5c200..f61373130 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, 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::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:;"; @@ -127,9 +147,10 @@ pub enum ApsRenderingMode { } /// 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, @@ -219,6 +240,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" @@ -280,12 +302,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( @@ -309,19 +331,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, @@ -337,20 +373,93 @@ 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, + /// Rendering owner for selected APS bids. + #[serde(default)] + pub rendering_mode: ApsRenderingMode, +} + +#[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, @@ -367,21 +476,510 @@ 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("error_type", json!("parse_response")) + .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) + .with_metadata("error_type", json!("http_status")) + .with_metadata("http_status", json!(status.as_u16())), + &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("error_type", json!("parse_response")) + .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 } } @@ -833,6 +1431,7 @@ impl ApsAuctionProvider { creative: None, adomain, bidder: APS_INTEGRATION_ID.to_string(), + returned_seat: None, width, height, nurl: None, @@ -1059,9 +1658,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 } @@ -1264,24 +1864,31 @@ impl IntegrationHeadInjector for ApsRendererIntegration { } } -/// Register the APS static renderer endpoint when APS is enabled. +/// Register renderer support when the auction plan contains an APS provider. +/// +/// Browser integration enablement does not control server-side APS rendering. +/// An absent or disabled browser block uses trusted-server rendering. An enabled +/// browser block may select publisher-native rendering. /// /// # Errors /// -/// Returns an error when enabled APS configuration is invalid. -pub fn register( +/// Returns an error when APS browser configuration is invalid. +pub fn register_for_plan( settings: &Settings, + plan: &crate::auction::AuctionPlan, ) -> Result, Report> { - let Some(config) = settings.integration_config::(APS_INTEGRATION_ID)? else { + if !plan.has_profile(APS_INTEGRATION_ID) { return Ok(None); - }; - let integration = Arc::new(ApsRendererIntegration { - rendering_mode: config.rendering_mode, - }); + } + let rendering_mode = settings + .integration_config::(APS_INTEGRATION_ID)? + .map(|config| config.rendering_mode) + .unwrap_or_default(); + let integration = Arc::new(ApsRendererIntegration { rendering_mode }); let registration = IntegrationRegistration::builder(APS_INTEGRATION_ID) .without_js() .with_head_injector(integration.clone()); - let registration = if config.rendering_mode == ApsRenderingMode::TrustedServer { + let registration = if rendering_mode == ApsRenderingMode::TrustedServer { registration.with_proxy(integration) } else { registration @@ -1294,10 +1901,54 @@ 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); + }; + let mut browser_settings = settings.clone(); + browser_settings.integrations.insert_config( + APS_INTEGRATION_ID, + &ApsConfig { + enabled: true, + rendering_mode: config.rendering_mode, + }, + )?; + register_for_plan( + &browser_settings, + &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"); @@ -1317,22 +1968,19 @@ 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::integrations::IntegrationDocumentState; - 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(), @@ -1407,6 +2055,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; @@ -1437,15 +2086,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 })) @@ -1462,11 +2124,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" })) @@ -1487,7 +2149,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 })) @@ -1504,15 +2166,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" })) @@ -1520,7 +2185,7 @@ mod tests { ); assert!( serde_json::from_value::(json!({ - "account_id": "example-account", + "enabled": true, "rendering_mode": "unsupported" })) .is_err(), @@ -1531,7 +2196,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 })) @@ -1572,7 +2237,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(), @@ -1583,7 +2248,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", @@ -1612,6 +2277,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; @@ -1641,54 +2307,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() @@ -1702,12 +2321,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); @@ -1766,6 +2386,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] @@ -1786,6 +2419,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; @@ -1802,13 +2436,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()); @@ -1963,6 +2601,7 @@ mod tests { settings: &settings, request: &downstream, timeout_ms: 321, + transport_timeout_ms: 321, provider_responses: None, services: &services, }; @@ -2419,6 +3058,61 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + fn plan_with_aps_profile() -> crate::auction::AuctionPlan { + crate::auction::AuctionPlan::compile(AuctionPlanConfig { + timeout_ms: 1_000, + 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 plan") + } + + #[test] + fn aps_plan_registers_trusted_server_renderer_without_enabled_browser_config() { + for disabled_browser_config in [false, true] { + let mut settings = create_test_settings(); + if disabled_browser_config { + settings + .integrations + .insert_config( + APS_INTEGRATION_ID, + &json!({ + "enabled": false, + "rendering_mode": "publisher_native" + }), + ) + .expect("should insert disabled APS browser config"); + } + + let registration = register_for_plan(&settings, &plan_with_aps_profile()) + .expect("should register APS renderer support") + .expect("should return APS renderer registration"); + + assert_eq!( + registration.proxies.len(), + 1, + "APS plan should register trusted-server renderer route" + ); + assert!( + registration.head_injectors[0] + .tsjs_script_tag_attributes() + .is_empty(), + "disabled or absent browser config should not select publisher-native rendering" + ); + } + } + #[test] fn enabled_config_registers_renderer_proxy() { let mut settings = create_test_settings(); 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 162e9eb8c..43eb1d9a4 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1638,7 +1638,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 { @@ -1678,7 +1685,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 { @@ -1744,7 +1758,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 { @@ -2070,7 +2091,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); @@ -2136,7 +2164,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); @@ -2202,7 +2237,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 b4a188f2a..1447a8358 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -467,7 +467,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 d0cf37275..c609aee51 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, LazyLock}; +#[cfg(test)] use std::time::Duration; use async_trait::async_trait; @@ -19,29 +22,45 @@ use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; use validator::{Validate, ValidationError}; +use crate::auction::openrtb::{ + BidRejectionReason, ResponseAdmissionDiagnostics, parse_optional_bid_dimension, + resolve_bid_dimensions, +}; 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::cache_policy::{CacheControlPolicy, EdgeCacheHeader}; 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}; @@ -55,8 +74,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. @@ -200,8 +222,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)] @@ -343,13 +366,109 @@ 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(regex( + path = *EXTERNAL_BUNDLE_SHA256_PATTERN, + message = "external_bundle_sha256 must be a 64-character hex SHA-256" + ))] + 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), @@ -406,7 +525,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) { @@ -416,11 +536,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); }; @@ -435,9 +556,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); }; @@ -479,6 +601,7 @@ fn default_timeout_ms() -> u32 { 1000 } +#[cfg(test)] fn default_bidders() -> Vec { vec!["mocktioneer".to_string()] } @@ -609,11 +732,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(), @@ -661,25 +784,96 @@ 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) +} + +pub(crate) fn validate_browser_bidder_ownership( + config: &PrebidIntegrationConfig, + plan: &AuctionPlan, +) -> Result<(), Report> { + if !plan.enabled() { + return Ok(()); + } + + let server_side = plan + .browser_bidder_codes() + .collect::>(); + let conflicts = config + .client_side_bidders + .iter() + .filter(|bidder| server_side.contains(bidder.as_str())) + .cloned() + .collect::>(); + if conflicts.is_empty() { + return Ok(()); + } + + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Prebid bidders must have exactly one browser owner; configured as both client-side and server-side: {}", + conflicts.into_iter().collect::>().join(", ") + ), + })) +} + +#[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), } } @@ -767,23 +961,53 @@ impl PrebidIntegration { Ok(response) } - 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 { @@ -952,6 +1176,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> { @@ -983,6 +1224,37 @@ 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)?; + validate_browser_bidder_ownership(&config, plan)?; + 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> { @@ -1072,12 +1344,31 @@ impl IntegrationAttributeRewriter for PrebidIntegration { } } +fn serialize_injected_prebid_config(payload: &impl Serialize) -> String { + // JSON appears in script raw-text, where every less-than sign must be escaped. + serde_json::to_string(payload) + .unwrap_or_else(|error| { + log::warn!("Prebid: failed to serialize client config: {error}"); + "{}".to_string() + }) + .replace('<', "\\u003c") +} + +fn injected_prebid_config_script(config_json: &str) -> 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> { @@ -1095,22 +1386,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()); @@ -1125,11 +1418,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, @@ -1187,7 +1482,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!( @@ -1204,7 +1500,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`. @@ -1227,8 +1523,9 @@ struct BidParamOverrideFacts<'a> { } impl BidParamOverrideEngine { + #[cfg(test)] fn try_from_config( - config: &PrebidIntegrationConfig, + config: &LegacyPrebidServerConfig, ) -> Result> { let mut rules = Vec::new(); @@ -1284,6 +1581,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) { @@ -1306,6 +1642,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>( @@ -1467,17 +1827,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()); } } @@ -1488,24 +1893,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); } } @@ -1518,6 +1919,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(); @@ -1529,30 +1931,315 @@ 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 Some(response) = response_json.as_object() else { + return AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", serde_json::json!("parse_response")); + }; + match response.get("cur") { + None => {} + Some(Json::String(currency)) if currency.eq_ignore_ascii_case(DEFAULT_CURRENCY) => {} + Some(Json::String(currency)) => { + return AuctionResponse::no_bid(provider_id, response_time_ms) + .with_metadata("unsupported_currency", serde_json::json!(currency)); + } + Some(_) => { + return AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", serde_json::json!("parse_response")); + } + } + + let mut diagnostics = ResponseAdmissionDiagnostics::default(); + 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, input) { + Ok(bid) => bids.push(bid), + Err(reason) => { + diagnostics.record(reason); + if reason == BidRejectionReason::InvalidBid { + 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}'" + ); + } + } + } + } + } + } + } + let mut parsed = if bids.is_empty() { + AuctionResponse::no_bid(provider_id, response_time_ms) + } else { + AuctionResponse::success(provider_id, bids, response_time_ms) + }; + diagnostics.attach_to(&mut parsed); + parsed +} + +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 parse_planned_prebid_bid( + bid: &Json, + delivery_bidder: &str, + returned_seat: Option<&str>, + input: &ProviderAuctionInput, +) -> Result { + let slot_id = bid + .get("impid") + .and_then(Json::as_str) + .filter(|slot_id| !slot_id.is_empty()) + .ok_or(BidRejectionReason::InvalidBid)? + .to_string(); + let width = parse_optional_bid_dimension(bid, "w")?; + let height = parse_optional_bid_dimension(bid, "h")?; + let (width, height) = resolve_bid_dimensions(input, &slot_id, width, height)?; + let price = bid + .get("price") + .and_then(Json::as_f64) + .filter(|price| price.is_finite() && *price >= 0.0) + .ok_or(BidRejectionReason::InvalidBid)?; + let creative = bid.get("adm").and_then(Json::as_str).map(String::from); + 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") } @@ -1561,7 +2248,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, @@ -2438,6 +3125,7 @@ impl PrebidAuctionProvider { creative, adomain, bidder: seat.to_string(), + returned_seat: None, width, height, nurl, @@ -2454,9 +3142,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 } @@ -2664,6 +3353,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> { @@ -2672,10 +3362,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, \ @@ -2693,7 +3385,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, }; @@ -2705,8 +3404,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, @@ -2718,8 +3418,9 @@ 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 _; #[test] fn external_bundle_sha256_validation_matches_hex_pattern() { @@ -2727,7 +3428,6 @@ mod tests { let config = |sha: &str| -> PrebidIntegrationConfig { serde_json::from_value(serde_json::json!({ - "server_url": "https://prebid.example.com/openrtb2/auction", "external_bundle_sha256": sha, })) .expect("should deserialize prebid config") @@ -2758,8 +3458,26 @@ mod tests { create_test_settings() } - fn base_config() -> PrebidIntegrationConfig { - PrebidIntegrationConfig { + #[test] + fn injected_prebid_config_escapes_every_less_than_sign() { + let config_json = serialize_injected_prebid_config(&json!({ + "accountId": "x 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 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] +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 = "all_eligible" + +[auction.providers.aps-primary.profile_config] +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_config] +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 | 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 + +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. +- 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 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. +- 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. + +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. + +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 + +### 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. 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 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: + +- Select its endpoint. +- Send HTTP requests. +- Register platform backends. +- Resolve secrets. +- Route other providers' bidders. +- 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: + +- 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 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 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 + +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 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: + +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. + +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 + +#### `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. 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 + +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 and applies the selected profile's compiled standard-field policy. + +### 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. +- 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. +- 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. +- 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 operator-configured arbitrary overrides of fields such as `site.domain`, `device.ip`, `user.id`, or `imp.tagid`. + +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 + +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 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. +- 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` | 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]`. + +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. +- Browser `timeout_ms` and `debug`, with their current defaults of 1000 ms and `false`, for the injected global Prebid.js configuration. + +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 + +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. +- 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 + +- 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` | 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, `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 + +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 version 1.1 wire contract. +- Avoid cryptographic or protocol redesign. +- Keep signing configuration global rather than repeated under providers. +- 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. + +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 + +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. + +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 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. + +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 + +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. +- Valid returned seat, when present. +- Delivery bidder code. +- 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 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, Mock Mediation, and Delivery + +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: + +- 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 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 + +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. 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: + +- `unroutable_bidder` +- `skipped_no_eligible_slots` +- `unused_bidder_params` + +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 + +```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. | +| 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. | +| 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. +- 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. +- 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.*]`, 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. +- [ ] 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. +- [ ] 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 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. +- [ ] Providers with no eligible slots are skipped without an upstream request. + +### OpenRTB and profiles + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- 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 + +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 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. + +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) diff --git a/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh index cc7e9eb87..f20d7c70d 100755 --- a/scripts/template-cache-local-test.sh +++ b/scripts/template-cache-local-test.sh @@ -24,6 +24,7 @@ esac REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$(mktemp -d)" ORIGIN_PORT="${ORIGIN_PORT:-9099}" +BID_PORT="${BID_PORT:-9100}" TS_PORT="${TS_PORT:-7788}" HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" @@ -31,6 +32,7 @@ HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" # observable in the timings: with an instant auction, buffered and streaming # assembly are indistinguishable. BID_DELAY="${BID_DELAY:-1.5}" +REQUEST_TIMEOUT_SECONDS="${REQUEST_TIMEOUT_SECONDS:-30}" PASS=0 FAIL=0 @@ -45,8 +47,16 @@ check() { # check cleanup() { local status=$? - [ -n "${VICEROY_PID:-}" ] && kill "$VICEROY_PID" 2>/dev/null || true - [ -n "${ORIGIN_PID:-}" ] && kill "$ORIGIN_PID" 2>/dev/null || true + if [ -n "${VICEROY_PID:-}" ]; then + kill "$VICEROY_PID" 2>/dev/null || true + wait "$VICEROY_PID" 2>/dev/null || true + fi + if [ -n "${ORIGIN_PID:-}" ]; then + # macOS may launch the framework Python process as a child of the shim. + pkill -TERM -P "$ORIGIN_PID" 2>/dev/null || true + kill "$ORIGIN_PID" 2>/dev/null || true + wait "$ORIGIN_PID" 2>/dev/null || true + fi rm -rf "$WORK" exit $status } @@ -60,13 +70,17 @@ command -v node >/dev/null || { echo "node not found. The harness executes the real GPT bundle to verify slot setup." >&2 exit 1 } +command -v openssl >/dev/null || { + echo "openssl not found. The harness needs it for the local HTTPS bid endpoint." >&2 + exit 1 +} # A port already in use means requests would go to something else entirely — most # likely a leftover run, whose warm cache and stale config would read as a result. -for port in "$ORIGIN_PORT" "$TS_PORT"; do +for port in "$ORIGIN_PORT" "$BID_PORT" "$TS_PORT"; do if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then echo "Port $port is already in use. Stop the process, or set" >&2 - echo "ORIGIN_PORT / TS_PORT to something free." >&2 + echo "ORIGIN_PORT / BID_PORT / TS_PORT to something free." >&2 lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 exit 1 fi @@ -78,19 +92,35 @@ cargo build -p trusted-server-cli --target "$HOST_TRIPLE" >/dev/null WASM="$REPO_ROOT/target/wasm32-wasip1/debug/trusted-server-adapter-fastly.wasm" TS="$REPO_ROOT/target/$HOST_TRIPLE/debug/ts" -info "Starting stub origin on :$ORIGIN_PORT" +info "Generating a local CA and HTTPS bid certificate" +openssl req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes \ + -subj "/CN=Trusted Server local harness CA" \ + -keyout "$WORK/ca-key.pem" -out "$WORK/ca-cert.pem" >/dev/null 2>&1 +openssl req -newkey rsa:2048 -sha256 -nodes -subj "/CN=localhost" \ + -keyout "$WORK/server-key.pem" -out "$WORK/server.csr" >/dev/null 2>&1 +cat > "$WORK/server.ext" <<'EOF' +basicConstraints=CA:FALSE +keyUsage=digitalSignature,keyEncipherment +extendedKeyUsage=serverAuth +subjectAltName=DNS:localhost,IP:127.0.0.1 +EOF +openssl x509 -req -sha256 -days 1 -in "$WORK/server.csr" \ + -CA "$WORK/ca-cert.pem" -CAkey "$WORK/ca-key.pem" -CAcreateserial \ + -extfile "$WORK/server.ext" -out "$WORK/server-cert.pem" >/dev/null 2>&1 + +info "Starting stub origin on :$ORIGIN_PORT and HTTPS bidder on :$BID_PORT" cat > "$WORK/origin.py" < "$WORK/origin.log" 2>&1 & ORIGIN_PID=$! sleep 1 info "Generating stub config (mode: $MODE)" -python3 - "$REPO_ROOT/trusted-server.example.toml" "$WORK/app.toml" "$MODE" "$ORIGIN_PORT" <<'PYEOF' -import sys, re -src, out, mode, port = sys.argv[1:5] +python3 - "$REPO_ROOT/trusted-server.example.toml" "$WORK/app.toml" "$MODE" \ + "$ORIGIN_PORT" "$BID_PORT" <<'PYEOF' +import re +import sys + +src, out, mode, origin_port, bid_port = sys.argv[1:6] s = open(src).read() -s = s.replace('origin_url = "https://origin.example.com"', f'origin_url = "http://127.0.0.1:{port}"', 1) -# The example config ships placeholders that validation rejects outright, -# including the reserved publisher domain/cookie_domain. -s = s.replace('domain = "example.com"', 'domain = "local-harness.example"', 1) -s = s.replace('cookie_domain = ".example.com"', 'cookie_domain = ".local-harness.example"', 1) -s = s.replace('password = "replace-with-admin-password-32-bytes"', - 'password = "local-harness-admin-password-not-a-real-one"', 1) -s = s.replace('proxy_secret = "change-me-proxy-secret"', - 'proxy_secret = "local-harness-proxy-secret-not-a-real-one"', 1) -s = re.sub(r'passphrase = "[^"]*"', - 'passphrase = "local-harness-ec-passphrase-not-a-real-one"', s, count=1) - -# A real auction, pointed at the stub's slow endpoint, so the timings mean something. -s = s.replace('[integrations.prebid]\nenabled = false\nserver_url = "https://prebid.example.com/openrtb2/auction"', - f'[integrations.prebid]\nenabled = true\nserver_url = "http://127.0.0.1:{port}/bid"\n' - 'external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-stub.js"', 1) -s = s.replace('providers = []', 'providers = ["prebid"]', 1) -s = s.replace('\n[proxy]\n', '\n[proxy]\nallowed_domains = ["assets.example.com", "127.0.0.1"]\n', 1) -s = s.replace('[auction]\nenabled = false', '[auction]\nenabled = true', 1) -s = s.replace('auction_timeout_ms = 500', 'auction_timeout_ms = 3000', 1) -s = s.replace('timeout_ms = 2000', 'timeout_ms = 3000', 1) +def replace_once(content, old, new, description): + if content.count(old) != 1: + raise SystemExit(f"expected one {description} replacement target") + return content.replace(old, new, 1) + + +s = replace_once( + s, + 'origin_url = "https://origin.example.com"', + f'origin_url = "http://127.0.0.1:{origin_port}"', + "publisher origin", +) +s = replace_once( + s, + 'domain = "example.com"', + 'domain = "local-harness.example"', + "publisher domain", +) +s = replace_once( + s, + 'cookie_domain = ".example.com"', + 'cookie_domain = ".local-harness.example"', + "publisher cookie domain", +) +# The example config ships placeholders that validation rejects outright. +s = replace_once( + s, + 'password = "replace-with-admin-password-32-bytes"', + 'password = "local-harness-admin-password-not-a-real-one"', + "admin password", +) +s = replace_once( + s, + 'proxy_secret = "change-me-proxy-secret"', + 'proxy_secret = "local-harness-proxy-secret-not-a-real-one"', + "proxy secret", +) +s, passphrase_count = re.subn( + r'passphrase = "[^"]*"', + 'passphrase = "local-harness-ec-passphrase-not-a-real-one"', + s, + count=1, +) +if passphrase_count != 1: + raise SystemExit("expected one EC passphrase replacement target") + +# A real auction points at the slow HTTPS stub so the timings mean something. +s = replace_once( + s, + '[integrations.prebid]\nenabled = false', + '[integrations.prebid]\nenabled = true\n' + 'external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-stub.js"', + "Prebid integration", +) +s = replace_once( + s, + 'endpoint = "https://prebid.example.com/openrtb2/auction"', + f'endpoint = "https://localhost:{bid_port}/bid"\ntimeout_ms = 5000', + "Prebid provider endpoint", +) +s = replace_once( + s, + '\n[proxy]\n', + '\n[proxy]\nallowed_domains = ["assets.example.com", "127.0.0.1"]\n', + "proxy table", +) +s = replace_once( + s, + '[auction]\n# Keep disabled until provider endpoints, routes, and profile values below are\n' + '# replaced with deployment-specific settings.\nenabled = false', + '[auction]\n# Keep disabled until provider endpoints, routes, and profile values below are\n' + '# replaced with deployment-specific settings.\nenabled = true', + "auction enablement", +) +s = replace_once( + s, + 'sanitize_creatives = false\ntimeout_ms = 2000', + 'sanitize_creatives = false\ntimeout_ms = 10000', + "auction timeout", +) +s = replace_once( + s, + 'auction_timeout_ms = 500', + 'auction_timeout_ms = 10000', + "creative opportunity auction timeout", +) # The template-cache keys go directly under the table header. The slot is a table of its own # and must go at the end: inserted here it would swallow every scalar key that @@ -230,6 +342,51 @@ info "Seeding an isolated config store (tracked fastly.toml remains untouched)" # pointed at this checkout without copying the workspace. cp "$REPO_ROOT/edgezero.toml" "$WORK/edgezero.toml" cp "$REPO_ROOT/fastly.toml" "$WORK/fastly.toml" + +# The application registers provider backends dynamically. Pre-register the exact +# deterministic name so Viceroy reuses a local backend that trusts the temporary CA. +python3 - "$WORK/fastly.toml" "$WORK/ca-cert.pem" "$BID_PORT" <<'PYEOF' +import hashlib +import json +import sys + +manifest, ca_certificate, port = sys.argv[1:4] +provider_id = "pbs-main" +timeout_ms = "5000" + + +def field(value): + return f"{len(value)}:{value}" + + +canonical = "".join([ + field("https"), + field("localhost"), + field(port), + field("1"), + "n", + "s", + field(provider_id), + field(timeout_ms), + field(timeout_ms), +]) +digest = hashlib.sha256(canonical.encode()).hexdigest()[:32] +readable = f"https_localhost_{port}_p_{provider_id}_fb{timeout_ms}_bb{timeout_ms}" +backend_name = f"backend_{readable}_{digest}" +backend = f'''[local_server.backends.{backend_name}] +url = "https://localhost:{port}" +cert_host = "localhost" +ca_certificate.file = {json.dumps(ca_certificate)} +''' + +content = open(manifest).read() +marker = "[local_server.backends]\n\n" +if content.count(marker) != 1: + raise SystemExit("expected one local backend insertion target") +content = content.replace(marker, f"{marker}{backend}\n", 1) +open(manifest, "w").write(content) +PYEOF + ln -s "$REPO_ROOT/crates" "$WORK/crates" (cd "$WORK" && "$TS" config push --adapter fastly --local \ --manifest "$WORK/edgezero.toml" --app-config "$WORK/app.toml" \ @@ -256,7 +413,7 @@ fi req() { # req [extra curl args...] local out="$1"; shift - curl -sS -D "$out.headers" -o "$out" \ + curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -D "$out.headers" -o "$out" \ -w '%{time_starttransfer} %{time_total} %{http_code}' \ -H "Host: ts.example.com" \ -H "Accept-Encoding: gzip" \ @@ -326,7 +483,8 @@ assembly_state() { # Shared by the ESI assertions below. check_hit_is_private() { local hdrs - hdrs=$(curl -s -D- -o /dev/null -H "Host: ts.example.com" \ + hdrs=$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -D- -o /dev/null \ + -H "Host: ts.example.com" \ -H "Accept-Encoding: gzip" \ -H "sec-fetch-dest: document" -H "sec-fetch-mode: navigate" \ "http://127.0.0.1:$TS_PORT/article") @@ -337,7 +495,8 @@ check_hit_is_private() { check_post_reaches_origin() { local before before=$(grep -cF "origin: received POST /article" "$WORK/origin.log" || true) - curl -s -o /dev/null -X POST -d 'x=1' -H "Host: ts.example.com" \ + curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -o /dev/null \ + -X POST -d 'x=1' -H "Host: ts.example.com" \ -H "Accept-Encoding: gzip" \ "http://127.0.0.1:$TS_PORT/article" check "a POST still reaches the origin" \ @@ -523,16 +682,17 @@ first chunk looks identical to one that does not. import socket, sys, time host, port, path = sys.argv[1], int(sys.argv[2]), sys.argv[3] -extra = sys.argv[4] if len(sys.argv) > 4 else "" +timeout = float(sys.argv[4]) req = ( f"GET {path} HTTP/1.1\r\nHost: ts.example.com\r\n" "sec-fetch-dest: document\r\nsec-fetch-mode: navigate\r\n" "accept-encoding: gzip\r\n" - f"{extra}Connection: close\r\n\r\n" + "Connection: close\r\n\r\n" ).encode() -s = socket.create_connection((host, port)) +s = socket.create_connection((host, port), timeout=timeout) +s.settimeout(timeout) t0 = time.time() s.sendall(req) @@ -569,7 +729,9 @@ cat <<'EOF' one that does not. EOF echo -probe_body_ms() { python3 "$WORK/probe.py" 127.0.0.1 "$TS_PORT" /article; } +probe_body_ms() { + python3 "$WORK/probe.py" 127.0.0.1 "$TS_PORT" /article "$REQUEST_TIMEOUT_SECONDS" +} echo " request A: $(probe_body_ms)" B_LINE="$(probe_body_ms)" echo " request B: $B_LINE" diff --git a/trusted-server.example.toml b/trusted-server.example.toml index b0e359cb4..f9daf58ed 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -238,10 +238,12 @@ pull_sync_concurrency = 3 # edge_ttl_seconds = 31536000 # immutable = true -# Server-side auction. Provider/mediator names must match enabled integrations. -# Kept active with the creative-processing leaves present so the EdgeZero -# environment override can apply to them. +# Server-side auction. Providers are declared below; mediator names must match +# enabled integrations. Kept active with the creative-processing leaves present +# so the EdgeZero environment override can apply to them. [auction] +# Keep disabled until provider endpoints, routes, and profile values below are +# replaced with deployment-specific settings. enabled = false # Rewrite winning-bid creative HTML to first-party endpoints (default true). Set # false to skip proxy/click-URL conversion and creative TSJS injection. @@ -259,13 +261,48 @@ rewrite_creatives = true # server's iframe), since it removes script-based creatives entirely and would # blank slots on a script-heavy demand stack. sanitize_creatives = false -providers = [] timeout_ms = 2000 # mediator = "adserver_mock" # optional mediator integration # Context keys the JS client may forward into auction requests (allowlist; # empty blocks all). allowed_context_keys = [] +# 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 +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 +# inventory_domain = "publisher.example" +# inventory_page_origin = "https://www.publisher.example" + # Server-side ad slot templates + creative-opportunity auction. Kept active. [creative_opportunities] # Set false to disable server-side ad templates while keeping slot definitions @@ -399,14 +436,13 @@ auction_timeout_ms = 500 # their section is commented out. Required fields are noted per block. # ============================================================================= -# Prebid Server-side auction + first-party Prebid.js bundle. -# When enabled: `server_url` is required, and `external_bundle_url` is required -# (its host must be listed in [proxy].allowed_domains). Kept active but disabled. +# Browser-side Prebid.js integration. Server-side bidder routing belongs under +# [auction.providers] and [auction.bidders]. When enabled, +# `external_bundle_url` is required and its host must be listed in +# [proxy].allowed_domains. Kept active but disabled. [integrations.prebid] enabled = false -server_url = "https://prebid.example.com/openrtb2/auction" timeout_ms = 1000 -bidders = [] debug = false client_side_bidders = [] # bidders running via native Prebid.js adapters # Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions. @@ -416,12 +452,6 @@ client_side_bidders = [] # bidders running via native Prebid.js adapter # external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" # external_bundle_sha256 = "" # external_bundle_sri = "" -# Per-bidder / per-zone param overrides (canonical rule form): -# [[integrations.prebid.bid_param_override_rules]] -# when.bidder = "examplebidder" -# when.zone = "header" -# set = { placementId = "_abc" } -# # Bundle build inputs consumed by the `ts prebid bundle` CLI (not the runtime): # [integrations.prebid.bundle] # adapters = ["rubicon"] @@ -527,24 +557,13 @@ gam_attribution_enabled = false # [integrations.gpt_diagnostics] # enabled = true -# Amazon Publisher Services (APS/TAM) OpenRTB. `account_id` required when -# enabled (`pub_id` is accepted as a deserialization alias only). +# APS browser renderer ownership. Server-side APS behavior belongs under an +# [auction.providers] entry with profile = "aps". # [integrations.aps] # enabled = true -# account_id = "example-aps-account-id" # required (non-empty); your APS account -# endpoint = "https://aps.example.com/e/pb/bid" -# timeout_ms = 1000 -# Include raw APS request/response data in /auction metadata on test sites only. -# debug = false -# Script creatives require separate security validation before opt-in. -# allow_script_creatives = false -# Winning-bid renderer. Default `trusted_server` uses TS's opaque static renderer -# route; set `publisher_native` only for the controlled publisher-origin -# friendly-frame experiment. +# Default `trusted_server` uses TS's opaque static renderer route. Set +# `publisher_native` only for a controlled publisher-origin friendly-frame cohort. # rendering_mode = "trusted_server" -# Set both when the deployment hostname differs from APS-authorized inventory. -# inventory_domain = "publisher.example" -# inventory_page_origin = "https://www.publisher.example" # Google Tag Manager first-party proxy. Kept active but disabled so `ts audit` # can fill container_id and flip `enabled` when GTM is detected. `container_id`