From f366018e4dd00b350fc0848405e968a20e20f7b7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 14 Oct 2026 22:27:56 +0100 Subject: [PATCH 1/7] The identifiers a request carries are the identity module's own type `Eid` and `Uid` move from core's OpenRTB module to `ec::eids`, beside the helpers that resolve them from the identity graph and carry them in the `x-ts-eids` header. The identity cookie, that header, the identify API and the auction request body all carry this shape, so it is the identity module's, and an auction protocol formats it into its own request from here. The OpenRTB user extension refers to the moved type and every import follows it. The serialization tests move with the types. --- crates/auction/prebid-server/src/lib.rs | 12 +-- .../src/auction/endpoints.rs | 5 +- .../src/auction/formats.rs | 2 +- .../src/auction/test_support.rs | 2 +- .../trusted-server-core/src/auction/types.rs | 2 +- crates/trusted-server-core/src/ec/eids.rs | 87 +++++++++++++++++-- crates/trusted-server-core/src/ec/finalize.rs | 2 +- crates/trusted-server-core/src/ec/identify.rs | 2 +- crates/trusted-server-core/src/ec/mod.rs | 2 +- .../trusted-server-core/src/ec/prebid_eids.rs | 2 +- crates/trusted-server-core/src/openrtb.rs | 71 +-------------- 11 files changed, 98 insertions(+), 91 deletions(-) diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index afedc6627a..540c40d6bc 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -4204,25 +4204,25 @@ assume_single_jurisdiction = true let provider = PrebidAuctionProvider::new(base_config()); let mut auction_request = create_test_auction_request(); auction_request.user.eids = Some(vec![ - trusted_server_core::openrtb::Eid { + trusted_server_core::ec::eids::Eid { source: "liveramp.com".to_owned(), - uids: vec![trusted_server_core::openrtb::Uid { + uids: vec![trusted_server_core::ec::eids::Uid { id: "LR_xyz".to_owned(), atype: Some(3), ext: None, }], }, - trusted_server_core::openrtb::Eid { + trusted_server_core::ec::eids::Eid { source: "id5-sync.com".to_owned(), - uids: vec![trusted_server_core::openrtb::Uid { + uids: vec![trusted_server_core::ec::eids::Uid { id: "ID5_abc".to_owned(), atype: Some(1), ext: None, }], }, - trusted_server_core::openrtb::Eid { + trusted_server_core::ec::eids::Eid { source: "google.com".to_owned(), - uids: vec![trusted_server_core::openrtb::Uid { + uids: vec![trusted_server_core::ec::eids::Uid { id: "pair-id".to_owned(), atype: Some(571187), ext: None, diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index bd265b4249..010afa10f4 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -14,13 +14,12 @@ use crate::constants::COOKIE_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; use crate::ec::EcKvSnapshot; -use crate::ec::eids::{resolve_partner_ids, to_eids}; +use crate::ec::eids::{Eid, Uid, resolve_partner_ids, to_eids}; use crate::ec::kv::KvIdentityGraph; use crate::ec::kv_types::MAX_UID_LENGTH; use crate::ec::prebid_eids::parse_prebid_eids_cookie; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; -use crate::openrtb::{Eid, Uid}; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -657,9 +656,9 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::ec::eids::Uid; use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingModule}; use crate::error::IntoHttpResponse as _; - use crate::openrtb::Uid; use crate::platform::test_support::{ NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient, noop_services, diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index f4d2c1c839..ca409f2f87 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -606,7 +606,7 @@ mod tests { use crate::auction::routing::route_auction; use crate::auction::test_support::{demand_table, plan_config}; use crate::auction::types::{AuctionResponse, Bid, BidRenderer, BidStatus}; - use crate::openrtb::{Eid, Uid}; + use crate::ec::eids::{Eid, Uid}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; use http::Method; diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index ee6f77ea7b..3d12cea6f7 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -13,8 +13,8 @@ use crate::auction::types::{ AdFormat, AdSlot, AuctionRequest, DeviceInfo, MediaType, PublisherInfo, UserInfo, }; use crate::consent::ConsentContext; +use crate::ec::eids::{Eid, Uid}; use crate::geo::GeoInfo; -use crate::openrtb::{Eid, Uid}; use crate::platform::{RuntimeServices, test_support::noop_services}; use crate::settings::Settings; diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 9d4bbcb47d..b2dea2753e 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -104,7 +104,7 @@ pub struct UserInfo { /// reaches a bid request — the provider layer just forwards already-gated /// EIDs. #[serde(skip)] - pub eids: Option>, + pub eids: Option>, } /// Device information from request. diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs index 1359f75091..cf4b3b7ec4 100644 --- a/crates/trusted-server-core/src/ec/eids.rs +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -1,18 +1,49 @@ -//! Shared EID resolution and formatting helpers. +//! The identifiers partners hold for a visitor, and the helpers that resolve +//! them from a KV entry and carry them in a response header. //! //! Used by both `/_ts/api/v1/identify` and `/auction` to resolve source-domain -//! keyed IDs from KV entries, convert them to `OpenRTB` EID structures, and -//! build base64-encoded response headers. +//! keyed IDs from KV entries, turn them into [`Eid`] entries, and build +//! base64-encoded response headers. use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use error_stack::{Report, ResultExt as _}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::error::TrustedServerError; -use crate::openrtb::{Eid, Uid}; use super::kv_types::KvEntry; use super::registry::PartnerRegistry; +/// The identifiers one identity provider holds for the visitor. +/// +/// This is the shape the identity cookie, the `x-ts-eids` response header, +/// the identify API and the auction request body carry, and the shape an +/// auction protocol formats into its own request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Eid { + /// The identity provider's source domain (for example `"id5-sync.com"`). + pub source: String, + /// One or more identifiers from this provider. + pub uids: Vec, +} + +/// A single identifier within an [`Eid`] entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Uid { + /// The identifier value. + pub id: String, + /// The agent type the identity provider declares for this identifier, + /// including vendor-specific values such as PAIR's `571187`. + #[serde(skip_serializing_if = "Option::is_none")] + pub atype: Option, + /// Provider-specific extension data. + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + /// Maximum size (in bytes) for the base64-encoded `x-ts-eids` header value. pub const MAX_EIDS_HEADER_BYTES: usize = 4096; @@ -24,7 +55,8 @@ pub struct ResolvedPartnerId { pub source_domain: String, /// The synced user ID value. pub uid: String, - /// `OpenRTB` agent type for this partner's identifiers. + /// The agent type this partner's identifiers carry, as `openrtb_atype` + /// in the partner's table sets it. pub openrtb_atype: i32, } @@ -59,7 +91,7 @@ pub fn resolve_partner_ids(registry: &PartnerRegistry, entry: &KvEntry) -> Vec Vec { resolved @@ -262,6 +294,49 @@ mod tests { ); } + #[test] + fn eid_serializes_correctly() { + let eid = Eid { + source: "id5-sync.com".to_owned(), + uids: vec![Uid { + id: "ID5-abc123".to_owned(), + atype: Some(1), + ext: None, + }], + }; + + let serialized = serde_json::to_value(&eid).expect("should serialize"); + assert_eq!(serialized["source"], "id5-sync.com", "source should match"); + assert_eq!( + serialized["uids"][0]["id"], "ID5-abc123", + "uid id should match" + ); + assert_eq!(serialized["uids"][0]["atype"], 1, "atype should be 1"); + assert!( + serialized["uids"][0].get("ext").is_none(), + "ext should be omitted when None" + ); + } + + #[test] + fn eid_serializes_vendor_specific_atype() { + let eid = Eid { + source: "google.com".to_owned(), + uids: vec![Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: None, + }], + }; + + let serialized = serde_json::to_value(&eid).expect("should serialize"); + + assert_eq!( + serialized["uids"][0]["atype"], 571187, + "should preserve PAIR's vendor-specific atype" + ); + } + #[test] fn build_eids_header_fits_without_truncation() { let resolved = vec![ResolvedPartnerId { diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 46bf43290e..80a3d89f48 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -622,8 +622,8 @@ mod tests { use super::*; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::ec::eids::{Eid, Uid}; use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY}; - use crate::openrtb::{Eid, Uid}; use crate::redacted::Redacted; use crate::settings::EcPartner; use crate::test_support::tests::{create_test_settings, purpose_one_only_consent}; diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 7e514e11de..43bbee326d 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,8 +10,8 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; +use crate::ec::eids::{Eid, Uid}; use crate::error::TrustedServerError; -use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; use super::EcContext; diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index e3b1265497..f85f73afa8 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -83,11 +83,11 @@ use crate::consent::{self as consent_mod, ConsentContext, ConsentPipelineInput}; use crate::constants::{COOKIE_EC, COOKIE_EC_PULL_COMPLETE}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; +use crate::ec::eids::Eid; use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; use crate::module_context::{ModuleContext, ModuleRequest, ResolvedRequest}; -use crate::openrtb::Eid; use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::prefix::PublicPrefix; diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 3f72fb620e..85fcc96032 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -12,7 +12,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde::Deserialize; use serde_json::Value as JsonValue; -use crate::openrtb::{Eid, Uid}; +use crate::ec::eids::{Eid, Uid}; use super::kv::PartnerIdUpdate; use super::kv_types::MAX_UID_LENGTH; diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 7260d8b134..d0633d0944 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -1,7 +1,7 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde::Serialize; use crate::auction::types::{BidRenderer, OrchestratorExt}; +use crate::ec::eids::Eid; pub type OpenRtbRequest = trusted_server_openrtb::BidRequest; pub type OpenRtbResponse = trusted_server_openrtb::BidResponse; @@ -64,30 +64,6 @@ pub struct ConsentedProvidersSettings { pub consented_providers: Option, } -/// An Extended User ID entry from an identity provider. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Eid { - /// Identity provider domain (e.g. `"id5-sync.com"`). - pub source: String, - /// One or more user IDs from this provider. - pub uids: Vec, -} - -/// A single user identifier within an [`Eid`] entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Uid { - /// The identifier value. - pub id: String, - /// `OpenRTB` agent type, including vendor-specific values such as PAIR's `571187`. - #[serde(skip_serializing_if = "Option::is_none")] - pub atype: Option, - /// Provider-specific extension data. - #[serde(skip_serializing_if = "Option::is_none")] - pub ext: Option, -} - /// Prebid-compatible `regs.ext` consent fields. /// /// Prebid Server reads consent signals from `regs.ext.*` rather than the @@ -348,47 +324,4 @@ mod tests { "consent should be omitted when None" ); } - - #[test] - fn eid_serializes_correctly() { - let eid = Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { - id: "ID5-abc123".to_owned(), - atype: Some(1), - ext: None, - }], - }; - - let serialized = serde_json::to_value(&eid).expect("should serialize"); - assert_eq!(serialized["source"], "id5-sync.com", "source should match"); - assert_eq!( - serialized["uids"][0]["id"], "ID5-abc123", - "uid id should match" - ); - assert_eq!(serialized["uids"][0]["atype"], 1, "atype should be 1"); - assert!( - serialized["uids"][0].get("ext").is_none(), - "ext should be omitted when None" - ); - } - - #[test] - fn eid_serializes_vendor_specific_atype() { - let eid = Eid { - source: "google.com".to_owned(), - uids: vec![Uid { - id: "pair-id".to_owned(), - atype: Some(571187), - ext: None, - }], - }; - - let serialized = serde_json::to_value(&eid).expect("should serialize"); - - assert_eq!( - serialized["uids"][0]["atype"], 571187, - "should preserve PAIR's vendor-specific atype" - ); - } } From c7c2680fd69594fe85936e0da30f2c2b7dec75fb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 14 Oct 2026 23:51:00 +0100 Subject: [PATCH 2/7] OpenRTB is a protocol module and core's auction names no protocol Core's demand seam is neutral. A compiled demand source builds its own request from the routed input, the transport facts and the signing input core settles, answering the body, the headers, the ids of the slots it asks for and any state it keeps for the answer, or nothing when it has nothing to ask for, and reads its own answer into core's bids. Core sends what the source built and reads the answer against those slots. The dimension admission rules every reader applies are `auction::admission`, the notification policy and the ignored bidder parameter count are the provider's, and the routed auction and its transport facts are public. Everything OpenRTB moves to `crates/auction-protocol/openrtb`: the wire types with their extension objects, the request builder, the response reader, the identifier formatting, and `OpenRtbDemand`, the hook trait a demand implementation that speaks OpenRTB fills in, run through core's seam by `OpenRtbDriver`. The plain implementation wraps its source in the driver, as `auction.prebid-server` and `auction.aps` now do, and `ad-server.mock` reads bids by core's admission rules. Core's own `openrtb` module, `auction/openrtb.rs` and its tests are gone, and core's test stand-ins speak a plain JSON of their own. The browser-facing response in `auction/formats.rs` still uses the generated types, so core's manifest keeps that one dependency for now. A deployment's settings do not change. --- crates/ad-server/mock/src/lib.rs | 6 +- crates/auction-protocol/openrtb/Cargo.toml | 17 +- crates/auction-protocol/openrtb/src/demand.rs | 266 +++++ crates/auction-protocol/openrtb/src/lib.rs | 76 +- .../auction-protocol/openrtb/src/request.rs | 377 +++++++ .../auction-protocol/openrtb/src/response.rs | 206 ++++ .../openrtb/src/test_support.rs | 48 + .../openrtb/src}/tests.rs | 791 ++++++++------- .../openrtb/src/wire.rs} | 180 ++-- crates/auction/aps/Cargo.toml | 2 + crates/auction/aps/src/lib.rs | 42 +- crates/auction/prebid-server/Cargo.toml | 2 + crates/auction/prebid-server/src/lib.rs | 37 +- .../src/auction/admission.rs | 361 +++++++ .../trusted-server-core/src/auction/demand.rs | 196 ++-- .../src/auction/formats.rs | 56 +- crates/trusted-server-core/src/auction/mod.rs | 6 +- .../src/auction/openrtb.rs | 925 ------------------ .../src/auction/openrtb/test_executor.rs | 107 -- .../src/auction/orchestrator.rs | 147 ++- .../trusted-server-core/src/auction/plan.rs | 2 +- .../src/auction/provider.rs | 457 +++++++-- .../src/auction/routing.rs | 49 +- .../src/auction/test_support.rs | 465 ++++++--- .../trusted-server-core/src/auction/types.rs | 27 +- crates/trusted-server-core/src/cookies.rs | 4 +- crates/trusted-server-core/src/ec/partner.rs | 2 +- crates/trusted-server-core/src/ec/registry.rs | 6 +- crates/trusted-server-core/src/lib.rs | 1 - .../src/platform/test_support.rs | 2 +- 30 files changed, 2813 insertions(+), 2050 deletions(-) create mode 100644 crates/auction-protocol/openrtb/src/demand.rs create mode 100644 crates/auction-protocol/openrtb/src/request.rs create mode 100644 crates/auction-protocol/openrtb/src/response.rs create mode 100644 crates/auction-protocol/openrtb/src/test_support.rs rename crates/{trusted-server-core/src/auction/openrtb => auction-protocol/openrtb/src}/tests.rs (61%) rename crates/{trusted-server-core/src/openrtb.rs => auction-protocol/openrtb/src/wire.rs} (65%) create mode 100644 crates/trusted-server-core/src/auction/admission.rs delete mode 100644 crates/trusted-server-core/src/auction/openrtb.rs delete mode 100644 crates/trusted-server-core/src/auction/openrtb/test_executor.rs diff --git a/crates/ad-server/mock/src/lib.rs b/crates/ad-server/mock/src/lib.rs index 07bd08e92f..1c57b29969 100644 --- a/crates/ad-server/mock/src/lib.rs +++ b/crates/ad-server/mock/src/lib.rs @@ -27,12 +27,12 @@ use std::sync::Arc; use std::time::Duration; use validator::Validate; -use trusted_server_core::auction::context::{ContextQueryParams, build_url_with_context_params}; -use trusted_server_core::auction::demand::AdServerImplementation; -use trusted_server_core::auction::openrtb::{ +use trusted_server_core::auction::admission::{ BidDimensionIndex, BidRejectionReason, build_bid_dimension_index_from_slots, parse_optional_bid_dimension, resolve_bid_dimensions, }; +use trusted_server_core::auction::context::{ContextQueryParams, build_url_with_context_params}; +use trusted_server_core::auction::demand::AdServerImplementation; use trusted_server_core::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use trusted_server_core::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, diff --git a/crates/auction-protocol/openrtb/Cargo.toml b/crates/auction-protocol/openrtb/Cargo.toml index 83cb18ec64..9a63bc6168 100644 --- a/crates/auction-protocol/openrtb/Cargo.toml +++ b/crates/auction-protocol/openrtb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trusted-server-auction-protocol-openrtb" -description = "The plain OpenRTB 2.6 demand implementation, for an exchange that needs no code of its own." +description = "OpenRTB 2.6 as an auction protocol: the request builder, the response reader and the hook trait a demand implementation that speaks it fills in, with the plain implementation for an exchange that needs no code of its own." authors = { workspace = true } edition = { workspace = true } license = { workspace = true } @@ -15,17 +15,28 @@ workspace = true [dependencies] async-trait = { workspace = true } +edgezero-core = { workspace = true } error-stack = { workspace = true } +http = { workspace = true } +log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-openrtb = { workspace = true } + +[features] +# Exposes the test helpers, so a demand implementation's own tests read the +# request the driver builds for it. +test-utils = ["trusted-server-core/test-utils"] [dev-dependencies] +futures = { workspace = true } trusted-server-core = { workspace = true, features = ["test-utils"] } +url = { workspace = true } # The visible owner of this crate, the way Prebid.js requires a named -# maintainer of every adapter. The plain OpenRTB implementation is the -# project's own, so the Trusted Server maintainers own it. +# maintainer of every adapter. The protocol and its plain implementation are +# the project's own, so the Trusted Server maintainers own them. [package.metadata.maintainers] owner = "Trusted Server maintainers" status = "project owned" diff --git a/crates/auction-protocol/openrtb/src/demand.rs b/crates/auction-protocol/openrtb/src/demand.rs new file mode 100644 index 0000000000..ae455395ee --- /dev/null +++ b/crates/auction-protocol/openrtb/src/demand.rs @@ -0,0 +1,266 @@ +//! What a demand source that speaks `OpenRTB` decides, and the driver that +//! runs it through core's demand seam. +//! +//! The driver builds every standard request field the same way for all of +//! them and reads a response the ordinary way. A source decides only what +//! [`OpenRtbDemand`] exposes, which is its field policy, its own extensions, +//! its outbound headers and how its responses are read. + +use core::any::Any; +use std::collections::HashSet; + +use async_trait::async_trait; +use error_stack::{Report, ResultExt as _}; +use http::{HeaderMap, HeaderValue, header}; +use serde_json::{Map, Value}; +use trusted_server_core::auction::demand::{ + CompiledDemand, DemandResponse, DemandTransport, OutboundRequest, ProviderAuctionInput, + ProviderSlotInput, RequestFinalization, +}; +use trusted_server_core::auction::types::AuctionResponse; +use trusted_server_core::consent::ConsentContext; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::platform::PlatformResponse; + +/// The longest primary `Accept-Language` tag a conservative source receives. +/// +/// A source that sets no limit receives the whole primary tag. +pub const CONSERVATIVE_LANGUAGE_MAX_BYTES: usize = 8; + +/// How the `regs` object is derived from the consent the auction admitted. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RegsPolicy { + /// No `regs` without consent data, and GDPR set from the applicability bit + /// or from a GDPR jurisdiction. + #[default] + Jurisdiction, + /// `regs` for any admitted consent, and GDPR set from the applicability bit + /// alone. + ApplicabilityBit, +} + +/// The standard `OpenRTB` field choices one demand source makes. +/// +/// The driver constructs every standard field and reads this policy while it +/// does. A policy can leave data out of what central privacy enforcement +/// approved, and it cannot add data that enforcement removed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DemandFieldPolicy { + /// Send the slot id as `imp.tagid`. + pub imp_tagid: bool, + /// Copy the first banner format into `banner.w` and `banner.h`, and send + /// `banner.topframe` as not in the top frame. + pub primary_banner_size: bool, + /// Send the accepted `Referer` header as `site.ref`. + pub site_ref: bool, + /// Send precise latitude and longitude with the coarse geo. + pub precise_geo: bool, + /// Send Google Additional Consent as `user.ext.ConsentedProvidersSettings`. + pub additional_consent: bool, + /// The longest primary `Accept-Language` tag sent, or `None` for no limit. + pub language_max_bytes: Option, + /// How `regs` is derived from consent. + pub regs: RegsPolicy, + /// Mark the request as a test request. + pub test: bool, + /// Keep the Trusted Server host and scheme in `ext.trusted_server` when the + /// request is not signed. + pub unsigned_request_identity: bool, + /// Send `Accept: application/json`. + pub accept_json: bool, + /// Treat routed bidder parameters as request data rather than reporting + /// them as parameters the source ignored. + pub consumes_bidder_params: bool, +} + +/// The extension objects a source may add to a request the driver built, one +/// for the request and one for each impression. +/// +/// This is the only part of the request a source writes. Every standard field +/// is the driver's, chosen through [`DemandFieldPolicy`], so a source can add +/// what its exchange needs and cannot change what privacy enforcement decided. +/// The `trusted_server` request extension is the driver's too, and a request +/// that claims it is refused. +pub struct RequestExtensions<'a> { + /// The request-level `ext` object. + pub request: &'a mut Option>, + /// The impressions the driver built, each beside the slot it came from. + pub impressions: Vec>, +} + +/// One impression's `ext` object beside the routed slot it was built from. +pub struct ImpressionExtension<'a> { + /// The routed slot, with its bidder parameters and zone. + pub slot: &'a ProviderSlotInput, + /// The impression's `ext` object. + pub ext: &'a mut Option>, + /// Set by a source that has no demand to send for this slot. The driver + /// leaves the impression out of the request, and makes no request at all + /// when every impression is left out. + pub omitted: bool, +} + +/// What one demand source that speaks `OpenRTB` decides, compiled from its +/// `[demand.]` table at startup and run by [`OpenRtbDriver`]. +/// +/// Every method except [`field_policy`](Self::field_policy), +/// [`augment_request`](Self::augment_request), +/// [`parse_response`](Self::parse_response) and [`as_any`](Self::as_any) has +/// a default that suits a source with no special handling. +#[async_trait(?Send)] +pub trait OpenRtbDemand: Send + Sync { + /// The standard field choices this source makes. + fn field_policy(&self) -> DemandFieldPolicy; + + /// The domain sent as `site.domain` and `site.publisher.domain`. + fn site_domain(&self, publisher_domain: &str) -> String { + publisher_domain.to_owned() + } + + /// The page sent as `site.page`, given the publisher's page and the site + /// domain [`site_domain`](Self::site_domain) chose. + fn site_page(&self, publisher_page: Option<&str>, site_domain: &str) -> Option { + let _ = site_domain; + publisher_page.map(str::to_owned) + } + + /// The consent that may travel in the request body. + fn body_consent<'c>(&self, consent: Option<&'c ConsentContext>) -> Option<&'c ConsentContext> { + consent + } + + /// Adds this source's own request and impression extensions. + /// + /// # Errors + /// + /// Returns an error when an extension cannot be built for this request. + fn augment_request( + &self, + extensions: &mut RequestExtensions<'_>, + input: &ProviderAuctionInput, + ) -> Result<(), Report>; + + /// Adds to the outbound headers before the request is sent, for example to + /// forward admitted headers. The driver has already set `Content-Type`, + /// and `Accept` when the field policy asks for it. + fn prepare_outbound(&self, headers: &mut HeaderMap, transport: DemandTransport<'_>) { + let _ = (headers, transport); + } + + /// Keeps request-local state the response parser needs, taken from the + /// serialized body and the outbound headers as they will be sent, after + /// [`prepare_outbound`](Self::prepare_outbound) has run. + fn capture_request( + &self, + body: &[u8], + headers: &HeaderMap, + ) -> Option> { + let _ = (body, headers); + None + } + + /// Reads the upstream response into an auction response. + /// + /// # Errors + /// + /// Returns an error when the response cannot be read at all. A response + /// that arrived but is not usable is reported as an error response rather + /// than as an `Err`. + async fn parse_response( + &self, + context: DemandResponse<'_>, + response: PlatformResponse, + ) -> Result>; + + /// This source as [`Any`], so the implementation that created it can read + /// its own settings back, for example while it registers routes. + fn as_any(&self) -> &dyn Any; +} + +/// Runs one [`OpenRtbDemand`] through core's demand seam. +/// +/// An implementation's `compile` wraps the source it compiled in this, so +/// core sends the request the driver builds and hands the answer back to the +/// source's own reader. Core's [`CompiledDemand::as_any`] answers with the +/// wrapped source, so the implementation reads its own settings back as if +/// nothing stood between. +pub struct OpenRtbDriver { + demand: Box, +} + +impl OpenRtbDriver { + /// The driver over one compiled source. + #[must_use] + pub fn new(demand: impl OpenRtbDemand + 'static) -> Self { + Self { + demand: Box::new(demand), + } + } + + /// The source the driver runs. + #[must_use] + pub fn demand(&self) -> &dyn OpenRtbDemand { + self.demand.as_ref() + } +} + +#[async_trait(?Send)] +impl CompiledDemand for OpenRtbDriver { + fn build_request( + &self, + input: &ProviderAuctionInput, + transport: DemandTransport<'_>, + timeout_ms: u32, + finalization: &RequestFinalization<'_>, + ) -> Result, Report> { + let demand = self.demand.as_ref(); + let Some(request) = + crate::request::build_request(demand, input, transport, timeout_ms, finalization)? + else { + return Ok(None); + }; + let slot_ids = request + .imp + .iter() + .filter_map(|impression| impression.id.clone()) + .collect::>(); + let body = serde_json::to_vec(&request).change_context(TrustedServerError::Auction { + message: format!( + "Provider {} request serialization failed", + input.provider_id().as_str() + ), + })?; + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + if demand.field_policy().accept_json { + headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); + } + demand.prepare_outbound(&mut headers, transport); + let captured = demand.capture_request(&body, &headers); + Ok(Some(OutboundRequest { + body, + headers, + slot_ids, + captured, + })) + } + + async fn parse_response( + &self, + context: DemandResponse<'_>, + response: PlatformResponse, + ) -> Result> { + self.demand.parse_response(context, response).await + } + + fn consumes_bidder_params(&self) -> bool { + self.demand.field_policy().consumes_bidder_params + } + + fn as_any(&self) -> &dyn Any { + self.demand.as_any() + } +} diff --git a/crates/auction-protocol/openrtb/src/lib.rs b/crates/auction-protocol/openrtb/src/lib.rs index 42a1a9c6fb..de2a7f9625 100644 --- a/crates/auction-protocol/openrtb/src/lib.rs +++ b/crates/auction-protocol/openrtb/src/lib.rs @@ -1,8 +1,16 @@ -//! The plain `OpenRTB` 2.6 demand implementation, `auction-protocol.openrtb`. +//! `OpenRTB` 2.6 as an auction protocol, and the plain demand implementation +//! that speaks it, `auction-protocol.openrtb`. //! -//! A standards-compliant exchange needs no code of its own. It takes the -//! shared request baseline, adds the static extensions its table sets, and has -//! its response read the ordinary `OpenRTB` way. +//! Core routes each demand source the slots, the publisher, the permission +//! state and the identifiers, and takes bids back. This crate turns that into +//! an `OpenRTB` bid request and reads an `OpenRTB` bid response, through +//! [`demand::OpenRtbDriver`], which runs any [`demand::OpenRtbDemand`] through +//! core's seam. A demand implementation that speaks `OpenRTB` depends on this +//! crate and decides only what [`demand::OpenRtbDemand`] exposes. +//! +//! The plain implementation is for a standards-compliant exchange that needs +//! no code of its own. It takes the shared request, adds the static +//! extensions its table sets, and has its response read the ordinary way. #![cfg_attr( test, @@ -16,6 +24,21 @@ ) )] +pub mod demand; +pub mod request; +pub mod response; +#[cfg(any(test, feature = "test-utils"))] +#[allow( + clippy::must_use_candidate, + clippy::missing_panics_doc, + reason = "test helpers, offered to other crates' tests under the test-utils feature" +)] +pub mod test_support; +pub mod wire; + +#[cfg(test)] +mod tests; + use core::any::Any; use std::sync::Arc; @@ -25,15 +48,19 @@ use serde::Deserialize; use serde_json::{Map, Value}; use trusted_server_core::auction::demand::{ - CONSERVATIVE_LANGUAGE_MAX_BYTES, CompiledDemand, DemandFieldPolicy, DemandImplementation, - DemandResponse, DemandTimeoutDefault, ProviderAuctionInput, RegsPolicy, RequestExtensions, - accept_endpoint, + CompiledDemand, DemandImplementation, DemandResponse, DemandTimeoutDefault, + ProviderAuctionInput, accept_endpoint, }; -use trusted_server_core::auction::openrtb::parse_openrtb_response; use trusted_server_core::auction::types::AuctionResponse; use trusted_server_core::error::TrustedServerError; use trusted_server_core::platform::PlatformResponse; +use crate::demand::{ + CONSERVATIVE_LANGUAGE_MAX_BYTES, DemandFieldPolicy, OpenRtbDemand, OpenRtbDriver, RegsPolicy, + RequestExtensions, +}; +use crate::response::parse_openrtb_response; + /// The name an `implementation` line gives this implementation, its module /// path, which also serves as its builder's id. pub const MODULE: &str = "auction-protocol.openrtb"; @@ -77,7 +104,7 @@ impl StaticExtension { /// One compiled plain `OpenRTB` demand source. #[derive(Debug, Clone, Default)] -pub struct OpenRtbDemand { +pub struct PlainDemand { /// Static request-level extension fields. pub request_ext: StaticExtension, /// Static impression-level extension fields. @@ -86,7 +113,7 @@ pub struct OpenRtbDemand { #[derive(Debug, Deserialize, Default)] #[serde(deny_unknown_fields)] -struct OpenRtbSettings { +struct PlainSettings { #[serde(default)] request_ext: Option, #[serde(default)] @@ -96,16 +123,16 @@ struct OpenRtbSettings { fn compile( settings: &Map, ) -> Result, Report> { - let settings = OpenRtbSettings::deserialize(Value::Object(settings.clone())) + let settings = PlainSettings::deserialize(Value::Object(settings.clone())) .map_err(|error| configuration_error(format!("invalid `{MODULE}` settings: {error}")))?; - Ok(Arc::new(OpenRtbDemand { + Ok(Arc::new(OpenRtbDriver::new(PlainDemand { request_ext: validate_static_extension("request_ext", settings.request_ext)?, imp_ext: validate_static_extension("imp_ext", settings.imp_ext)?, - })) + }))) } #[async_trait(?Send)] -impl CompiledDemand for OpenRtbDemand { +impl OpenRtbDemand for PlainDemand { fn field_policy(&self) -> DemandFieldPolicy { DemandFieldPolicy { language_max_bytes: Some(CONSERVATIVE_LANGUAGE_MAX_BYTES), @@ -216,7 +243,7 @@ fn reject_reserved_fields( object: &Map, ) -> Result<(), Report> { let reserved: &[&str] = match field { - "request_ext" => &["trusted_server"], + "request_ext" => &[crate::request::TRUSTED_SERVER_EXT_KEY], _ => &[], }; if let Some(key) = reserved.iter().find(|key| object.contains_key(**key)) { @@ -228,18 +255,19 @@ fn reject_reserved_fields( } #[cfg(test)] -mod tests { +mod plain_tests { use serde_json::{Map, Value, json}; use trusted_server_core::auction::plan::AuctionPlan; use trusted_server_core::auction::test_support::{ - build_for_first_source, canonical_parity_auction_request, demand_table, - deterministic_signer, golden_inbound_request, golden_plan_config, plan_config_with, + canonical_parity_auction_request, demand_table, deterministic_signer, + golden_inbound_request, golden_plan_config, plan_config_with, }; use trusted_server_core::error::TrustedServerError; - use trusted_server_core::openrtb::OpenRtbRequest; use trusted_server_core::request_signing::RequestSigner; - use super::{MODULE, OpenRtbDemand, builder}; + use super::{MODULE, PlainDemand, builder}; + use crate::test_support::build_for_first_source; + use crate::wire::BidRequest; /// One `[demand.]` table running this implementation. fn table() -> Map { @@ -253,9 +281,9 @@ mod tests { AuctionPlan::compile(plan_config_with(vec![("one", table)], &[builder()])) } - /// The request core's driver builds for one source running this + /// The request the driver builds for one source running this /// implementation with `settings`, signed when `signer` is given. - fn build(settings: Value, signer: Option<&RequestSigner>) -> OpenRtbRequest { + fn build(settings: Value, signer: Option<&RequestSigner>) -> BidRequest { let plan = AuctionPlan::compile(golden_plan_config(MODULE, settings, true, &[builder()])) .expect("should compile plan"); build_for_first_source( @@ -297,8 +325,8 @@ mod tests { let openrtb = plan.providers()[0] .demand .as_any() - .downcast_ref::() - .expect("should compile the OpenRTB implementation"); + .downcast_ref::() + .expect("should compile the plain implementation"); assert_eq!( openrtb.request_ext.as_object()["fictional_account"], "example" diff --git a/crates/auction-protocol/openrtb/src/request.rs b/crates/auction-protocol/openrtb/src/request.rs new file mode 100644 index 0000000000..4036a436b3 --- /dev/null +++ b/crates/auction-protocol/openrtb/src/request.rs @@ -0,0 +1,377 @@ +//! The `OpenRTB` 2.6 request the driver builds for every source that speaks +//! it, from the facts core routed and nothing else. + +use error_stack::Report; +use serde_json::Map; +use trusted_server_core::auction::demand::{ + DemandTransport, ProviderAuctionInput, ProviderSlotInput, RequestFinalization, TransportHeaders, +}; +use trusted_server_core::consent::ConsentContext; +use trusted_server_core::consent::jurisdiction::Jurisdiction; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::request_signing::SIGNING_VERSION; + +use crate::demand::{ + DemandFieldPolicy, ImpressionExtension, OpenRtbDemand, RegsPolicy, RequestExtensions, +}; +use crate::wire::{ + Banner, BidRequest, ConsentedProvidersSettings, Device, Format, Geo, Imp, Publisher, Regs, + RegsExt, Site, ToExt as _, TrustedServerExt, User, UserExt, format_eids, to_openrtb_i32, +}; + +/// The currency every request asks for and every bid is read in. +pub const DEFAULT_CURRENCY: &str = "USD"; + +/// The request extension the driver writes and no source may claim. +pub const TRUSTED_SERVER_EXT_KEY: &str = "trusted_server"; + +/// Builds one source's request from its routed input, or `None` when the +/// source is left with no impression to send. +/// +/// Every standard field is built here from the routed facts, chosen through +/// the source's [`DemandFieldPolicy`]. The source then adds its own +/// extensions, and the request is signed when a signer is given. +/// +/// # Errors +/// +/// Returns an auction error when the source's own extensions cannot be built, +/// the source claims the extension the driver owns, or the signing input does +/// not bind the request's id. +pub fn build_request( + demand: &dyn OpenRtbDemand, + input: &ProviderAuctionInput, + transport: DemandTransport<'_>, + timeout_ms: u32, + finalization: &RequestFinalization<'_>, +) -> Result, Report> { + let policy = demand.field_policy(); + let (mut request, slot_indices) = + build_common_request(input, transport, demand, policy, timeout_ms); + if request.imp.is_empty() { + return Ok(None); + } + let omitted = { + // The source sees the extension objects and the slot each impression + // came from, and nothing else of the request. + let BidRequest { ext, imp, .. } = &mut request; + let impressions = imp + .iter_mut() + .zip(&slot_indices) + .map(|(imp, &index)| ImpressionExtension { + slot: &input.slots()[index], + ext: &mut imp.ext, + omitted: false, + }) + .collect(); + let mut extensions = RequestExtensions { + request: ext, + impressions, + }; + demand.augment_request(&mut extensions, input)?; + extensions + .impressions + .iter() + .map(|impression| impression.omitted) + .collect::>() + }; + // An impression the source left without demand goes, and so does a + // request that has none left, so a demand source is never asked for + // nothing. + if omitted.iter().any(|&omitted| omitted) { + let mut omitted = omitted.into_iter(); + request.imp.retain(|_| !omitted.next().unwrap_or(false)); + } + if request.imp.is_empty() { + return Ok(None); + } + if request + .ext + .as_ref() + .is_some_and(|ext| ext.contains_key(TRUSTED_SERVER_EXT_KEY)) + { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} set ext.{TRUSTED_SERVER_EXT_KEY}, which only the driver writes", + input.provider_id().as_str() + ), + })); + } + finalize_request(&mut request, policy, finalization)?; + Ok(Some(request)) +} + +/// Builds every standard field of the request. The second value holds, for +/// each impression built, the index of the routed slot it came from. +fn build_common_request( + input: &ProviderAuctionInput, + transport: DemandTransport<'_>, + demand: &dyn OpenRtbDemand, + policy: DemandFieldPolicy, + timeout_ms: u32, +) -> (BidRequest, Vec) { + let common = input.common_request(); + let (slot_indices, imps): (Vec, Vec) = input + .slots() + .iter() + .enumerate() + .filter_map(|(index, slot)| build_imp(slot, policy).map(|imp| (index, imp))) + .unzip(); + let site_domain = demand.site_domain(&common.publisher.domain); + let page = demand.site_page(common.publisher.page_url.as_deref(), &site_domain); + let body_consent = demand.body_consent(common.user.consent.as_ref()); + 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: policy + .additional_consent + .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.as_deref().map(format_eids), + } + .to_ext(), + ..Default::default() + }); + let language = normalized_language(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: policy.precise_geo.then_some(geo.latitude), + lon: policy.precise_geo.then_some(geo.longitude), + metro: (geo.metro_code > 0).then(|| geo.metro_code.to_string()), + r#type: Some(2), + ..Default::default() + }), + dnt: transport.dnt, + language: language.clone(), + // Attributes the selected device module resolved, each absent + // when nothing resolved it. + devicetype: device.attributes.as_ref().and_then(|a| a.device_type), + make: device.attributes.as_ref().and_then(|a| a.make.clone()), + model: device.attributes.as_ref().and_then(|a| a.model.clone()), + os: device.attributes.as_ref().and_then(|a| a.os.clone()), + osv: device + .attributes + .as_ref() + .and_then(|a| a.os_version.clone()), + w: device.attributes.as_ref().and_then(|a| a.screen_width), + h: device.attributes.as_ref().and_then(|a| a.screen_height), + ..Default::default() + }) + .or_else(|| { + (transport.dnt.is_some() || language.is_some()).then_some(Device { + dnt: transport.dnt, + language, + ..Default::default() + }) + }); + + let request = BidRequest { + id: Some(common.id.clone()), + imp: imps, + site: Some(Site { + domain: Some(site_domain.clone()), + page, + r#ref: policy + .site_ref + .then(|| header_string(transport.headers.referer())) + .flatten(), + publisher: Some(Publisher { + domain: Some(site_domain), + ..Default::default() + }), + ..Default::default() + }), + user, + device, + regs: build_regs(body_consent, policy.regs), + test: policy.test.then_some(true), + tmax: to_openrtb_i32(timeout_ms, "tmax", "config-first provider request"), + cur: vec![DEFAULT_CURRENCY.to_string()], + ..Default::default() + }; + (request, slot_indices) +} + +fn build_imp(slot: &ProviderSlotInput, policy: DemandFieldPolicy) -> 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 primary_size = policy.primary_banner_size; + Some(Imp { + id: Some(slot.slot().id.clone()), + banner: Some(Banner { + format: formats, + w: primary_size.then_some(first_width).flatten(), + h: primary_size.then_some(first_height).flatten(), + topframe: primary_size.then_some(false), + ..Default::default() + }), + tagid: policy.imp_tagid.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 finalize_request( + request: &mut BidRequest, + policy: DemandFieldPolicy, + 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.unsigned_request_identity { + 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_EXT_KEY.to_string(), serialized); + } + Ok(()) +} + +fn build_regs(consent: Option<&ConsentContext>, policy: RegsPolicy) -> Option { + let consent = consent?; + if policy == RegsPolicy::ApplicabilityBit { + // Any admitted context produces regs and GDPR comes from the + // applicability bit alone, 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(), + }); + } + + // The jurisdiction policy sends no regs for an empty context and reads + // GDPR from the applicability bit or from a GDPR jurisdiction. + 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, Jurisdiction::Gdpr) { + Some(true) + } else if matches!(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: &TransportHeaders, policy: DemandFieldPolicy) -> 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.language_max_bytes { + None => Some(value), + Some(max_bytes) => (value.len() <= max_bytes).then_some(value), + } +} + +fn header_string(value: Option<&http::HeaderValue>) -> Option { + value + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} diff --git a/crates/auction-protocol/openrtb/src/response.rs b/crates/auction-protocol/openrtb/src/response.rs new file mode 100644 index 0000000000..b90a4e8ae3 --- /dev/null +++ b/crates/auction-protocol/openrtb/src/response.rs @@ -0,0 +1,206 @@ +//! Reading an ordinary `OpenRTB` 2.6 bid response into core's bids. + +use std::collections::HashMap; + +use error_stack::{Report, ResultExt as _}; +use http::StatusCode; +use serde_json::{Value, json}; +use trusted_server_core::auction::admission::{ + BidDimensionIndex, BidRejectionReason, ResponseAdmissionDiagnostics, build_bid_dimension_index, + parse_optional_bid_dimension, resolve_bid_dimensions, +}; +use trusted_server_core::auction::demand::{DemandResponse, ProviderAuctionInput}; +use trusted_server_core::auction::types::{AuctionResponse, Bid}; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::platform::PlatformResponse; + +use crate::request::DEFAULT_CURRENCY; + +/// The most a demand source's response body may hold. +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + +/// Reads an ordinary `OpenRTB` response. A `204` is a no-bid, any other +/// non-success status or unreadable JSON is an error response, and a body over +/// the size limit is an `Err`. +/// +/// A demand implementation whose exchange answers the ordinary way has its +/// response read through this. +/// +/// # Errors +/// +/// Returns an error when the response body cannot be read. +pub async fn parse_openrtb_response( + context: DemandResponse<'_>, + response: PlatformResponse, +) -> Result> { + let provider_id = context.provider_id; + let response_time_ms = context.response_time_ms; + let response = response.response; + let status = response.status(); + if status == StatusCode::NO_CONTENT { + return Ok(AuctionResponse::no_bid(provider_id, response_time_ms)); + } + if !status.is_success() { + if status.is_redirection() { + log::warn!( + "Provider '{provider_id}' returned a redirect; generic OpenRTB redirects are refused" + ); + } + return Ok(AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("http_status")) + .with_metadata("http_status", json!(status.as_u16()))); + } + + let body = response + .into_body() + .into_bytes_bounded(MAX_RESPONSE_BYTES) + .await + .change_context(TrustedServerError::Auction { + message: format!("Provider {provider_id} response body failed"), + })?; + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(error) => { + log::warn!("Provider '{provider_id}' response JSON was invalid: {error}"); + return Ok(AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("parse_response"))); + } + }; + + Ok(extract_standard_response( + provider_id, + context.input, + &value, + response_time_ms, + )) +} + +/// Reads ordinary `OpenRTB` bids independently, admitting each against the +/// slots the request asked for. The response id is informational. +#[must_use] +pub 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 dimensions_by_slot = build_bid_dimension_index(input); + 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, &dimensions_by_slot) { + 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>, + dimensions_by_slot: &BidDimensionIndex, +) -> 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(dimensions_by_slot, &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(), + }) +} diff --git a/crates/auction-protocol/openrtb/src/test_support.rs b/crates/auction-protocol/openrtb/src/test_support.rs new file mode 100644 index 0000000000..75eb2741cb --- /dev/null +++ b/crates/auction-protocol/openrtb/src/test_support.rs @@ -0,0 +1,48 @@ +//! Helpers for tests of a demand implementation that speaks `OpenRTB`, in +//! this crate and in an implementation's own. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt as _}; +use http::Request; +use trusted_server_core::auction::AuctionPlan; +use trusted_server_core::auction::types::AuctionRequest; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::request_signing::RequestSigner; + +use crate::wire::BidRequest; + +/// Builds the request the first demand source of `plan` would send once +/// `request` is routed to it, read back as the bid request it is, or `None` +/// when the source keeps no impression. +/// +/// A test in an implementation's own crate checks what its field policy and +/// its extensions do to a request through this. The signature, when `signer` +/// is given, is made over the signing input core's test support fixes. +/// +/// # Errors +/// +/// Returns the error the request builder returns, or an auction error when +/// the body the driver built does not read back as a bid request. +pub fn build_for_first_source( + plan: &AuctionPlan, + request: AuctionRequest, + inbound: &Request, + transport_timeout_ms: u32, + signer: Option<&RequestSigner>, +) -> Result, Report> { + let Some(outbound) = trusted_server_core::auction::test_support::build_for_first_source( + plan, + request, + inbound, + transport_timeout_ms, + signer, + )? + else { + return Ok(None); + }; + serde_json::from_slice(&outbound.body) + .map(Some) + .change_context(TrustedServerError::Auction { + message: "the body the driver built does not read back as a bid request".to_string(), + }) +} diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/auction-protocol/openrtb/src/tests.rs similarity index 61% rename from crates/trusted-server-core/src/auction/openrtb/tests.rs rename to crates/auction-protocol/openrtb/src/tests.rs index af47ffad9c..701c7eee37 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/auction-protocol/openrtb/src/tests.rs @@ -1,30 +1,175 @@ +//! Tests of the driver over the plain implementation and over a stand-in +//! that departs from it. + 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::demand::DemandResponse; -use crate::auction::plan::{ - AuctionPlan, AuctionPlanConfig, BidderId, BidderRouteConfig, ProviderId, +use trusted_server_core::auction::demand::{ + CompiledDemand, DemandResponse, ProviderAuctionInput, RequestFinalization, RoutedAuction, +}; +use trusted_server_core::auction::plan::{ + AuctionPlan, AuctionPlanConfig, BidderId, BidderRouteConfig, NotificationPolicy, ProviderId, + ProviderPlan, }; -use crate::auction::provider::{GenericOpenRtbProvider, ProviderRequestOutcome}; -use crate::auction::routing::route_auction; -use crate::auction::test_support::{canonical_parity_auction_request, demand_table, plan_config}; -use crate::auction::types::{AdFormat, AdSlot, BidStatus, MediaType}; -use crate::consent::ConsentContext; -use crate::consent::jurisdiction::Jurisdiction; -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 trusted_server_core::auction::provider::apply_notification_policy; +use trusted_server_core::auction::test_support::{ + canonical_parity_auction_request, demand_table, deterministic_signer, parse_as_first_source, + plan_config_with, route_for_test, route_with_trusted_routes_for_test, }; -use crate::platform::{PlatformHttpClient, PlatformResponse}; -use crate::request_signing::RequestSigner; +use trusted_server_core::auction::types::{ + AdFormat, AdSlot, AuctionResponse, BidStatus, MediaType, +}; +use trusted_server_core::consent::ConsentContext; +use trusted_server_core::consent::jurisdiction::Jurisdiction; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::platform::PlatformResponse; +use trusted_server_core::request_signing::{RequestSigner, SigningParams}; + +use crate::demand::{ + DemandFieldPolicy, OpenRtbDemand, OpenRtbDriver, RegsPolicy, RequestExtensions, +}; +use crate::request::build_request; +use crate::response::extract_standard_response; +use crate::wire::BidRequest; +use crate::{MODULE as PLAIN, PlainDemand, builder as plain_builder}; + +/// A stand-in demand implementation that departs from the plain one. It +/// serves stored requests, forbids all-eligible routing, takes bidder +/// parameters, keeps the unsigned request identity and completes an endpoint +/// that names a host alone. +/// +/// It writes each impression's usable bidder parameters under +/// `ext.fixture.bidder`, or the slot id under `ext.fixture.stored` where the +/// slot allows a stored request, and leaves out an impression that has +/// neither. +mod fixture { + use core::any::Any; + use std::sync::Arc; + + use async_trait::async_trait; + use error_stack::Report; + use serde_json::{Map, Value, json}; + use trusted_server_core::auction::demand::{ + CompiledDemand, DemandImplementation, DemandResponse, DemandTimeoutDefault, + ProviderAuctionInput, + }; + use trusted_server_core::auction::types::AuctionResponse; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::IntegrationBuilder; + use trusted_server_core::platform::PlatformResponse; + use url::Url; + + use crate::demand::{ + DemandFieldPolicy, OpenRtbDemand, OpenRtbDriver, RegsPolicy, RequestExtensions, + }; + use crate::response::parse_openrtb_response; + + pub(super) const MODULE: &str = "auction-protocol.fixture"; + pub(super) const AUCTION_PATH: &str = "/fixture/auction"; + + pub(super) static DEMAND: DemandImplementation = DemandImplementation { + id: MODULE, + default_timeout: DemandTimeoutDefault::Fixed(1000), + allows_all_eligible: false, + serves_stored_requests: true, + canonicalize_endpoint, + compile, + }; + + pub(super) fn builder() -> IntegrationBuilder { + IntegrationBuilder::implementations(MODULE, "fixture").with_demand(&DEMAND) + } + + #[derive(Debug, Clone, Default)] + pub(super) struct FixtureDemand; + + fn compile( + _settings: &Map, + ) -> Result, Report> { + Ok(Arc::new(OpenRtbDriver::new(FixtureDemand))) + } + + fn canonicalize_endpoint(endpoint: &mut Url) -> Result<(), String> { + if matches!(endpoint.path(), "" | "/") { + endpoint.set_path(AUCTION_PATH); + } + Ok(()) + } + + #[async_trait(?Send)] + impl OpenRtbDemand for FixtureDemand { + fn field_policy(&self) -> DemandFieldPolicy { + DemandFieldPolicy { + imp_tagid: true, + site_ref: true, + precise_geo: true, + additional_consent: true, + unsigned_request_identity: true, + consumes_bidder_params: true, + regs: RegsPolicy::Jurisdiction, + ..DemandFieldPolicy::default() + } + } + + fn augment_request( + &self, + extensions: &mut RequestExtensions<'_>, + _input: &ProviderAuctionInput, + ) -> Result<(), Report> { + for impression in &mut extensions.impressions { + let slot = impression.slot; + let bidder = slot + .bidder_params() + .iter() + .filter(|(_, params)| { + params.as_object().is_some_and(|params| !params.is_empty()) + }) + .map(|(bidder, params)| (bidder.as_str().to_string(), params.clone())) + .collect::>(); + let mut fixture = Map::new(); + if !bidder.is_empty() { + fixture.insert("bidder".to_string(), Value::Object(bidder)); + } else if slot.allows_stored_fallback() { + fixture.insert("stored".to_string(), json!(slot.slot().id)); + } + if fixture.is_empty() { + impression.omitted = true; + continue; + } + *impression.ext = Some(Map::from_iter([( + "fixture".to_string(), + Value::Object(fixture), + )])); + } + *extensions.request = Some(Map::from_iter([( + "fixture".to_string(), + Value::Object(Map::new()), + )])); + Ok(()) + } + + async fn parse_response( + &self, + context: DemandResponse<'_>, + response: PlatformResponse, + ) -> Result> { + parse_openrtb_response(context, response).await + } + + fn as_any(&self) -> &dyn Any { + self + } + } +} + +const FIXTURE: &str = fixture::MODULE; + +fn builders() -> [trusted_server_core::integrations::IntegrationBuilder; 2] { + [plain_builder(), fixture::builder()] +} fn config(implementation: &str, settings: Value) -> AuctionPlanConfig { config_with_endpoint( @@ -41,7 +186,7 @@ fn config_with_endpoint( ) -> AuctionPlanConfig { let provider_id = ProviderId::from_str("fictional_provider").expect("should parse provider ID"); // The stand-in forbids all-eligible routing, so a bidder is routed to it. - let routed_by_bidder = implementation == "auction.fixture"; + let routed_by_bidder = implementation == FIXTURE; let mut table = demand_table(implementation, endpoint); table.insert("timeout_ms".to_string(), json!(321)); if !routed_by_bidder { @@ -50,7 +195,7 @@ fn config_with_endpoint( if let Value::Object(settings) = settings { table.extend(settings); } - let mut config = plan_config(vec![("fictional_provider", table)]); + let mut config = plan_config_with(vec![("fictional_provider", table)], &builders()); config.timeout_ms = 321; if routed_by_bidder { config.bidders = BTreeMap::from([( @@ -75,14 +220,14 @@ fn routed(implementation: &str, settings: Value) -> (AuctionPlan, RoutedAuction) .header("dnt", "1") .body(EdgeBody::empty()) .expect("should build inbound request"); - let routed = route_auction(canonical_parity_auction_request(), &inbound, &plan, None); + let routed = route_for_test(&plan, canonical_parity_auction_request(), &inbound, None); (plan, routed) } fn routed_with_request( implementation: &str, settings: Value, - request: crate::auction::types::AuctionRequest, + request: trusted_server_core::auction::types::AuctionRequest, accept_language: Option<&str>, ) -> (AuctionPlan, RoutedAuction) { let plan = AuctionPlan::compile(config(implementation, settings)).expect("should compile plan"); @@ -93,29 +238,18 @@ fn routed_with_request( let inbound = builder .body(EdgeBody::empty()) .expect("should build inbound request"); - let routed = route_auction(request, &inbound, &plan, None); + let routed = route_for_test(&plan, request, &inbound, None); (plan, routed) } -fn build_with_request( - implementation: &str, - settings: Value, - request: crate::auction::types::AuctionRequest, - accept_language: Option<&str>, -) -> OpenRtbRequest { - let (plan, routed) = routed_with_request(implementation, settings, 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"), +/// The source a provider plan runs, as the driver sees it. +fn demand_of(provider: &ProviderPlan) -> &dyn OpenRtbDemand { + let any = provider.demand.as_any(); + if let Some(plain) = any.downcast_ref::() { + return plain; } + any.downcast_ref::() + .expect("the plan should run one of the two implementations under test") } fn finalization<'a>(signer: Option<&'a RequestSigner>) -> RequestFinalization<'a> { @@ -130,38 +264,46 @@ fn finalization<'a>(signer: Option<&'a RequestSigner>) -> RequestFinalization<'a } } -fn build(implementation: &str, settings: Value, signer: Option<&RequestSigner>) -> OpenRtbRequest { - let (plan, routed) = routed(implementation, settings); - match build_request( +fn build_routed( + plan: &AuctionPlan, + routed: &RoutedAuction, + signer: Option<&RequestSigner>, +) -> Result, error_stack::Report> { + build_request( + demand_of(&plan.providers()[0]), &routed.inputs()[0], - &routed, - &plan.providers()[0], + routed.transport(), 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") +fn build(implementation: &str, settings: Value, signer: Option<&RequestSigner>) -> BidRequest { + let (plan, routed) = routed(implementation, settings); + build_routed(&plan, &routed, signer) + .expect("should build request") + .expect("should retain impression") +} + +fn build_with_request( + implementation: &str, + settings: Value, + request: trusted_server_core::auction::types::AuctionRequest, + accept_language: Option<&str>, +) -> BidRequest { + let (plan, routed) = routed_with_request(implementation, settings, request, accept_language); + build_routed(&plan, &routed, None) + .expect("should build request") + .expect("should retain impression") +} + +fn response(status: u16, body: Vec) -> PlatformResponse { + PlatformResponse::new( + edgezero_core::http::response_builder() + .status(status) + .body(EdgeBody::from(body)) + .expect("should build the upstream response"), + ) } #[test] @@ -205,7 +347,7 @@ fn consent_matrix_holds_under_each_implementation() { ), ]; for (name, consent) in cases { - for implementation in ["auction.plain-fixture", "auction.fixture"] { + for implementation in [PLAIN, FIXTURE] { let mut canonical = canonical_parity_auction_request(); canonical.user.consent = Some(consent.clone()); let value = serde_json::to_value(build_with_request( @@ -252,10 +394,7 @@ fn consent_matrix_holds_under_each_implementation() { #[test] fn language_limits_are_profile_specific() { let language = "abcdefghijk"; - for (implementation, expected) in [ - ("auction.fixture", Some(language)), - ("auction.plain-fixture", None), - ] { + for (implementation, expected) in [(FIXTURE, Some(language)), (PLAIN, None)] { let request = build_with_request( implementation, json!({}), @@ -267,7 +406,7 @@ fn language_limits_are_profile_specific() { expected ); } - for implementation in ["auction.fixture", "auction.plain-fixture"] { + for implementation in [FIXTURE, PLAIN] { let request = build_with_request( implementation, json!({}), @@ -281,10 +420,24 @@ fn language_limits_are_profile_specific() { } } +#[test] +fn the_identifiers_the_request_carries_are_sent_as_eids() { + let request = build(PLAIN, json!({}), None); + let value = serde_json::to_value(request).expect("should serialize request"); + assert_eq!( + value["user"]["ext"]["eids"], + json!([{"source": "identity.example", "uids": [{"atype": 1, "id": "fictional-uid"}]}]), + "the identifiers core routed should be formatted as the user's eids" + ); + assert_eq!( + value["user"]["id"], "fictional-user", + "the user id travels beside them" + ); +} + #[test] fn filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { - let plan = - AuctionPlan::compile(config("auction.fixture", json!({}))).expect("should compile plan"); + let plan = AuctionPlan::compile(config(FIXTURE, json!({}))).expect("should compile plan"); let mut request = canonical_parity_auction_request(); let template = request.slots[0].clone(); request.slots = [ @@ -313,31 +466,20 @@ fn filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { }) .collect(); let inbound = Request::new(EdgeBody::empty()); - let routes = crate::auction::routing::TrustedProviderRoutes::new(vec![ - vec![], - vec![], - vec![plan.providers()[0].id.clone()], - vec![], - ]); - let routed = crate::auction::routing::route_auction_with_trusted_routes( - request, &inbound, &plan, None, &routes, + let routed = route_with_trusted_routes_for_test( + &plan, + request, + &inbound, + &[&[], &[], &["fictional_provider"], &[]], ); assert_eq!( routed.inputs()[0].slots().len(), 4, "should preserve server-owned route admission" ); - let result = build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build request"); - let OpenRtbBuildOutcome::Ready(request) = result else { - panic!("should retain siblings") - }; + let request = build_routed(&plan, &routed, None) + .expect("should build request") + .expect("should retain siblings"); let wire = serde_json::to_value(request).expect("should serialize request"); assert_eq!( wire["imp"] @@ -357,9 +499,9 @@ fn filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { #[test] fn a_disabled_empty_candidate_does_not_become_stored_demand() { - let mut raw = config("auction.fixture", json!({"routing": "explicit"})); + let mut raw = config(FIXTURE, json!({"routing": "explicit"})); raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), + BidderId::from_str("exampleBidder").expect("should parse bidder"), BidderRouteConfig { module: ProviderId::from_str("fictional_provider").expect("should parse provider"), }, @@ -374,39 +516,31 @@ fn a_disabled_empty_candidate_does_not_become_stored_demand() { .uri("https://publisher.example.com/auction") .body(EdgeBody::empty()) .expect("should build inbound request"); - let routed = route_auction(request, &inbound, &plan, None); + let routed = route_for_test(&plan, request, &inbound, None); assert_eq!( routed.inputs().len(), 1, "should route candidate for overrides" ); - assert!(matches!( - build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build request"), - OpenRtbBuildOutcome::NoImpressions - )); + assert!( + build_routed(&plan, &routed, None) + .expect("should build request") + .is_none(), + "a source left with no impression asks for nothing" + ); } #[test] fn signing_finalization_is_after_profiles_and_asserts_every_owned_key() { let signer = deterministic_signer(); for (implementation, config) in [ - ( - "auction.plain-fixture", - json!({"request_ext": {"fictional": true}}), - ), - ("auction.fixture", json!({})), + (PLAIN, json!({"request_ext": {"fictional": true}})), + (FIXTURE, json!({})), ] { let unsigned = serde_json::to_value(build(implementation, config.clone(), None)) .expect("should serialize unsigned request"); let unsigned_ts = unsigned["ext"].get("trusted_server"); - if implementation == "auction.fixture" { + if implementation == FIXTURE { assert_eq!( unsigned_ts, Some(&json!({"request_host": "publisher.example", "request_scheme": "https"})), @@ -442,66 +576,10 @@ fn signing_finalization_is_after_profiles_and_asserts_every_owned_key() { } } -#[test] -fn signed_profiles_and_unsigned_standard_have_exact_full_goldens() { - let signer = deterministic_signer(); - let cases = [( - "auction.plain-fixture", - 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"}}}"#, - )]; - for (implementation, config, expected) in cases { - assert_eq!( - serde_json::to_string(&build(implementation, config, Some(&signer))) - .expect("should serialize signed request"), - expected, - "{implementation} signed wire fixture should stay exact" - ); - } - - assert_eq!( - serde_json::to_string(&build( - "auction.plain-fixture", - 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 defensive_no_impression_outcome_does_not_build_transportable_request() { - let (plan, mut routed) = routed("auction.plain-fixture", 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) { +/// The plain source over one bidder-routed slot of the given formats. +fn standard_input_with_formats(formats: Vec) -> ProviderAuctionInput { let mut raw = config( - "auction.plain-fixture", + PLAIN, json!({"request_ext": {"fixture": true}, "imp_ext": {"slot_fixture": true}}), ); raw.bidders.insert( @@ -517,24 +595,11 @@ fn standard_fixture_with_formats( .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) + route_for_test(&plan, auction_request, &inbound, None).inputs()[0].clone() } -fn standard_fixture() -> (AuctionPlan, RoutedAuction, OpenRtbRequest) { - standard_fixture_with_formats(vec![AdFormat { +fn standard_input() -> ProviderAuctionInput { + standard_input_with_formats(vec![AdFormat { media_type: MediaType::Banner, width: 300, height: 250, @@ -543,10 +608,10 @@ fn standard_fixture() -> (AuctionPlan, RoutedAuction, OpenRtbRequest) { #[test] fn standard_response_extraction_isolates_malformed_siblings_and_ignores_response_id() { - let (_plan, routed, _request) = standard_fixture(); + let input = standard_input(); let response = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &json!({ "id": "informational-mismatch", "seatbid": [{"seat": "fictional-seat", "bid": [ @@ -566,7 +631,7 @@ fn standard_response_extraction_isolates_malformed_siblings_and_ignores_response #[test] fn standard_response_currency_accepts_omitted_and_usd_but_rejects_other_or_malformed_values() { - let (_plan, routed, _request) = standard_fixture(); + let input = standard_input(); let bid = json!({"seatbid": [{"seat": "seat", "bid": [ {"id":"good","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250} ]}]}); @@ -576,8 +641,7 @@ fn standard_response_currency_accepts_omitted_and_usd_but_rejects_other_or_malfo if let Some(currency) = currency { value["cur"] = currency; } - let response = - extract_standard_response("fictional_provider", &routed.inputs()[0], &value, 0); + let response = extract_standard_response("fictional_provider", &input, &value, 0); assert_eq!( response.status, BidStatus::Success, @@ -588,24 +652,23 @@ fn standard_response_currency_accepts_omitted_and_usd_but_rejects_other_or_malfo let mut eur = bid.clone(); eur["cur"] = json!("EUR"); - let response = extract_standard_response("fictional_provider", &routed.inputs()[0], &eur, 0); + let response = extract_standard_response("fictional_provider", &input, &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); + let response = extract_standard_response("fictional_provider", &input, &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 input = standard_input(); let response = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &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}, @@ -632,10 +695,10 @@ fn standard_response_rejects_unknown_impressions_and_dimensions_but_keeps_siblin #[test] fn standard_response_infers_only_unambiguous_missing_dimensions() { - let (_plan, routed, _request) = standard_fixture(); + let input = standard_input(); let inferred = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &json!({"seatbid": [{"bid": [ {"id":"inferred","impid":"fictional-slot","price":1.0,"adm":"ok"} ]}]}), @@ -657,10 +720,10 @@ fn standard_response_infers_only_unambiguous_missing_dimensions() { height: 50, }, ]; - let (_plan, routed, _request) = standard_fixture_with_formats(formats); + let input = standard_input_with_formats(formats); let ambiguous = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &json!({"seatbid": [{"bid": [ {"id":"ambiguous","impid":"fictional-slot","price":1.0,"adm":"ok"} ]}]}), @@ -691,11 +754,11 @@ fn standard_response_infers_dimensions_from_partial_unique_matches() { height: 50, }, ]; - let (_plan, routed, _request) = standard_fixture_with_formats(formats); + let input = standard_input_with_formats(formats); let response = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &json!({"seatbid": [{"bid": [ {"id":"width-only","impid":"fictional-slot","price":1.0,"adm":"width","w":300}, {"id":"height-only","impid":"fictional-slot","price":2.0,"adm":"height","h":50} @@ -723,11 +786,11 @@ fn standard_response_infers_dimensions_from_partial_unique_matches() { #[test] fn standard_response_accepts_null_and_integral_float_dimensions() { - let (_plan, routed, _request) = standard_fixture(); + let input = standard_input(); let response = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &json!({"seatbid": [{"bid": [ {"id":"null","impid":"fictional-slot","price":1.0,"adm":"null","w":null,"h":null}, {"id":"integral-float","impid":"fictional-slot","price":2.0,"adm":"float","w":300.0,"h":250.0} @@ -773,11 +836,11 @@ fn standard_response_rejects_partial_dimension_mismatch_and_ambiguity() { height: 50, }, ]; - let (_plan, routed, _request) = standard_fixture_with_formats(formats); + let input = standard_input_with_formats(formats); let response = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &json!({"seatbid": [{"bid": [ {"id":"ambiguous","impid":"fictional-slot","price":1.0,"adm":"ambiguous","w":300}, {"id":"mismatch","impid":"fictional-slot","price":2.0,"adm":"mismatch","w":999} @@ -802,11 +865,11 @@ fn standard_response_rejects_partial_dimension_mismatch_and_ambiguity() { #[test] fn standard_response_rejects_invalid_numeric_dimension_shapes() { - let (_plan, routed, _request) = standard_fixture(); + let input = standard_input(); let response = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &json!({"seatbid": [{"bid": [ {"id":"fractional","impid":"fictional-slot","price":1.0,"adm":"bad","w":300.5}, {"id":"zero","impid":"fictional-slot","price":1.0,"adm":"bad","w":0}, @@ -834,10 +897,10 @@ fn standard_response_rejects_invalid_numeric_dimension_shapes() { #[test] fn notification_suppression_matrix_uses_only_exact_valid_returned_seat() { - let (_plan, routed, _request) = standard_fixture(); + let input = standard_input(); let response = extract_standard_response( "fictional_provider", - &routed.inputs()[0], + &input, &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"}]}, @@ -876,186 +939,117 @@ fn notification_suppression_matrix_uses_only_exact_valid_returned_seat() { ); } +/// The driver hands core the body and headers the source sends, and the +/// responses an exchange may answer with are each read as the ordinary +/// reader classifies them. #[test] -fn fictional_standard_executor_covers_bid_no_bid_malformed_unused_and_redirect() { +fn the_driver_builds_what_core_sends_and_classifies_every_answer() { 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 mut raw = config( + PLAIN, + json!({"request_ext": {"fixture": true}, "imp_ext": {"slot_fixture": true}}), ); - 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"); + raw.bidders.insert( + BidderId::from_str("exampleBidder").expect("should parse bidder"), + BidderRouteConfig { + module: ProviderId::from_str("fictional_provider").expect("should parse provider"), + }, + ); + let plan = AuctionPlan::compile(raw).expect("should compile the plan"); + let inbound = Request::new(EdgeBody::empty()); + let routed = route_for_test(&plan, canonical_parity_auction_request(), &inbound, None); + let outbound = plan.providers()[0] + .demand + .build_request( + &routed.inputs()[0], + routed.transport(), + 321, + &finalization(None), + ) + .expect("should build the request") + .expect("should keep the impression"); assert_eq!( - response.metadata["routing"]["unused_bidder_params_count"], - 1 + outbound + .headers + .get(header::CONTENT_TYPE) + .map(|v| v.as_bytes()), + Some(b"application/json".as_slice()) ); assert_eq!( - client.recorded_request_uris(), - vec![provider.endpoint.as_str()] + outbound.headers.get(header::ACCEPT).map(|v| v.as_bytes()), + Some(b"application/json".as_slice()), + "the plain implementation asks for JSON" ); - let headers = &client.recorded_request_headers()[0]; assert!( - !headers.iter().any(|(name, _)| name == "authorization"), - "fixture must add no authentication" + !outbound.headers.contains_key(header::AUTHORIZATION), + "the plain implementation adds no authentication" + ); + assert_eq!( + outbound.slot_ids, + HashSet::from(["fictional-slot".to_string()]), + "the slots the request asks for are the impressions it carries" ); - let body: Value = serde_json::from_slice(&client.recorded_request_bodies()[0]) - .expect("should parse recorded body"); + let body: Value = serde_json::from_slice(&outbound.body).expect("should parse the body"); assert_eq!(body["ext"]["fixture"], true, "should send static extension"); + assert_eq!(body["imp"][0]["ext"]["slot_fixture"], true); - 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(), + let good = 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 parsed = parse_as_first_source(&plan, canonical_parity_auction_request(), good, 5) + .await + .expect("should read the response"); + assert_eq!(parsed.status, BidStatus::Success); + assert_eq!(parsed.bids.len(), 1, "should isolate malformed sibling"); + assert_eq!( + parsed.metadata["routing"]["unused_bidder_params_count"], 1, + "the plain implementation ignores the bidder parameters it is routed" + ); + + let no_bid = parse_as_first_source( + &plan, + canonical_parity_auction_request(), + response(204, Vec::new()), + 5, ) .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(), + .expect("should read the no-bid"); + assert_eq!(no_bid.status, BidStatus::NoBid); + + let malformed = parse_as_first_source( + &plan, + canonical_parity_auction_request(), + response(200, b"not-json".to_vec()), + 5, ) .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(), + assert_eq!(malformed.status, BidStatus::Error); + assert_eq!(malformed.metadata["error_type"], "parse_response"); + + let redirect = parse_as_first_source( + &plan, + canonical_parity_auction_request(), + response(302, Vec::new()), + 5, ) .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 a_completed_endpoint_reaches_generic_execution_and_a_custom_path_is_kept() { - futures::executor::block_on(async { - for (configured_endpoint, expected_endpoint) in [ - ( - "https://exchange.example", - "https://exchange.example/fixture/auction", - ), - ( - "https://exchange.example/bid", - "https://exchange.example/bid", - ), - ] { - let plan = AuctionPlan::compile(config_with_endpoint( - "auction.fixture", - json!({}), - configured_endpoint, - )) - .expect("should compile the 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 request"); - let ProviderRequestOutcome::Pending { request, .. } = outcome else { - panic!("should launch a pending request"); - }; - let selected = services - .http_client() - .select(vec![request]) - .await - .expect("should select one response"); - let response = selected.ready.expect("should receive the 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, "exchange.example"); - let body: Value = serde_json::from_slice(&client.recorded_request_bodies()[0]) - .expect("should parse the recorded body"); - assert!( - !body - .as_object() - .expect("should serialize an object") - .is_empty() - ); - } + assert_eq!(redirect.status, BidStatus::Error); + assert_eq!(redirect.metadata["error_type"], "http_status"); + assert_eq!(redirect.metadata["http_status"], 302); }); } #[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); + let input = standard_input(); + let response = extract_standard_response("fictional_provider", &input, &json!([]), 0); assert_eq!(response.status, BidStatus::Error); assert_eq!(response.metadata["error_type"], "parse_response"); } @@ -1064,7 +1058,7 @@ fn malformed_top_level_standard_response_is_error() { struct ForgingDemand; #[async_trait::async_trait(?Send)] -impl CompiledDemand for ForgingDemand { +impl OpenRtbDemand for ForgingDemand { fn field_policy(&self) -> DemandFieldPolicy { DemandFieldPolicy::default() } @@ -1073,7 +1067,7 @@ impl CompiledDemand for ForgingDemand { &self, extensions: &mut RequestExtensions<'_>, _input: &ProviderAuctionInput, - ) -> Result<(), Report> { + ) -> Result<(), error_stack::Report> { *extensions.request = Some(serde_json::Map::from_iter([( "trusted_server".to_string(), json!({"signature": "forged"}), @@ -1085,7 +1079,7 @@ impl CompiledDemand for ForgingDemand { &self, context: DemandResponse<'_>, _response: PlatformResponse, - ) -> Result> { + ) -> Result> { Ok(AuctionResponse::error( context.provider_id, context.response_time_ms, @@ -1099,14 +1093,12 @@ impl CompiledDemand for ForgingDemand { #[test] fn the_driver_refuses_an_implementation_that_claims_the_trusted_server_extension() { - let (plan, routed) = routed("auction.plain-fixture", json!({})); - let mut provider = plan.providers()[0].clone(); - provider.demand = Arc::new(ForgingDemand); + let (_plan, routed) = routed(PLAIN, json!({})); let error = match build_request( + &ForgingDemand, &routed.inputs()[0], - &routed, - &provider, + routed.transport(), 321, &finalization(None), ) { @@ -1120,21 +1112,25 @@ fn the_driver_refuses_an_implementation_that_claims_the_trusted_server_extension ); } +#[test] +fn the_driver_answers_as_any_with_the_source_it_runs() { + let driver = OpenRtbDriver::new(ForgingDemand); + assert!( + driver.as_any().is::(), + "an implementation reads its own source back through the driver" + ); + assert!( + !driver.consumes_bidder_params(), + "the policy of the source it runs decides" + ); +} + #[test] fn an_implementation_sees_each_impression_beside_the_slot_it_came_from() { - let (plan, routed) = routed("auction.fixture", json!({})); - let request = match build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build the request") - { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => panic!("should keep the routed impression"), - }; + let (plan, routed) = routed(FIXTURE, json!({})); + let request = build_routed(&plan, &routed, None) + .expect("should build the request") + .expect("should keep the routed impression"); let slots = routed.inputs()[0].slots(); assert_eq!(request.imp.len(), slots.len()); @@ -1150,13 +1146,13 @@ fn an_implementation_sees_each_impression_beside_the_slot_it_came_from() { } } -fn request_with_device_attributes() -> crate::auction::types::AuctionRequest { +fn request_with_device_attributes() -> trusted_server_core::auction::types::AuctionRequest { let mut request = canonical_parity_auction_request(); request .device .as_mut() .expect("the parity request carries a device") - .attributes = Some(crate::ec::device::DeviceAttributes { + .attributes = Some(trusted_server_core::ec::device::DeviceAttributes { device_type: Some(4), make: Some("ExampleCorp".to_owned()), model: Some("EX-1".to_owned()), @@ -1172,7 +1168,7 @@ fn request_with_device_attributes() -> crate::auction::types::AuctionRequest { /// resolved reach every implementation without any of them mapping a field. #[test] fn the_driver_carries_resolved_device_attributes_to_every_implementation() { - for implementation in ["auction.fixture", "auction.plain-fixture"] { + for implementation in [FIXTURE, PLAIN] { let request = build_with_request( implementation, json!({}), @@ -1207,12 +1203,7 @@ fn the_driver_carries_resolved_device_attributes_to_every_implementation() { #[test] fn the_driver_omits_device_attributes_nothing_resolved() { - let request = build_with_request( - "auction.fixture", - json!({}), - canonical_parity_auction_request(), - None, - ); + let request = build_with_request(FIXTURE, json!({}), canonical_parity_auction_request(), None); let device = request .device .as_ref() diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/auction-protocol/openrtb/src/wire.rs similarity index 65% rename from crates/trusted-server-core/src/openrtb.rs rename to crates/auction-protocol/openrtb/src/wire.rs index d0633d0944..491a6d3c89 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/auction-protocol/openrtb/src/wire.rs @@ -1,18 +1,22 @@ -use serde::Serialize; - -use crate::auction::types::{BidRenderer, OrchestratorExt}; -use crate::ec::eids::Eid; +//! The `OpenRTB` 2.6 wire types the driver writes and reads, and the +//! extension objects this project adds to them. -pub type OpenRtbRequest = trusted_server_openrtb::BidRequest; -pub type OpenRtbResponse = trusted_server_openrtb::BidResponse; -pub type OpenRtbBid = trusted_server_openrtb::Bid; +use serde::Serialize; pub use trusted_server_openrtb::{ - Banner, Bid, BidResponse, Device, Format, Geo, Imp, Publisher, Regs, SeatBid, Site, ToExt, User, + Banner, Bid, BidRequest, BidResponse, Device, Eid, Format, Geo, Imp, Object, Publisher, Regs, + SeatBid, Site, ToExt, Uid, User, }; -/// Convert a `u32` value to `i32` for `OpenRTB` fields, logging a warning and -/// returning `None` if the value exceeds `i32::MAX`. +/// The request the driver sends. +pub type OpenRtbRequest = BidRequest; +/// The response an exchange answers with. +pub type OpenRtbResponse = BidResponse; +/// One bid in a response. +pub type OpenRtbBid = Bid; + +/// Converts a `u32` to the `i32` an `OpenRTB` field holds, logging and +/// returning `None` when the value does not fit. #[must_use] pub fn to_openrtb_i32(value: u32, field_name: &str, context: &str) -> Option { if let Ok(converted) = i32::try_from(value) { @@ -25,24 +29,45 @@ pub fn to_openrtb_i32(value: u32, field_name: &str, context: &str) -> Option Vec { + eids.iter() + .map(|eid| Eid { + source: Some(eid.source.clone()), + uids: eid + .uids + .iter() + .map(|uid| Uid { + id: Some(uid.id.clone()), + atype: uid.atype, + ext: uid.ext.as_ref().and_then(|ext| match ext { + serde_json::Value::Object(object) => Some(object.clone()), + _ => None, + }), + ..Default::default() + }) + .collect(), + ..Default::default() + }) + .collect() +} +/// The `user.ext` object. #[derive(Debug, Serialize)] pub struct UserExt { - /// TCF v2 consent string (Prebid reads `user.ext.consent`). + /// TCF v2 consent string, which Prebid reads from `user.ext.consent`. #[serde(skip_serializing_if = "Option::is_none")] pub consent: Option, - /// Google Additional Consent settings for Ad Manager / `AdX` demand. + /// Google Additional Consent settings for Ad Manager and `AdX` demand. #[serde( rename = "ConsentedProvidersSettings", skip_serializing_if = "Option::is_none" )] pub consented_providers_settings: Option, - /// Extended User IDs from identity providers. - /// - /// Gated by TCF Purpose 1 (storage) and Purpose 4 (personalized ads). + /// The identifiers the request carries, already gated by the permissions + /// the auction resolved. #[serde(skip_serializing_if = "Option::is_none")] pub eids: Option>, } @@ -67,8 +92,7 @@ pub struct ConsentedProvidersSettings { /// Prebid-compatible `regs.ext` consent fields. /// /// Prebid Server reads consent signals from `regs.ext.*` rather than the -/// `OpenRTB` 2.6 top-level locations. We populate both to maximise -/// compatibility (see proposal Key Decision #2 — Dual-Placement). +/// `OpenRTB` 2.6 top-level locations, so the driver writes both. #[derive(Debug, Clone, Serialize, Default)] pub struct RegsExt { /// GDPR applicability flag (mirrors `regs.gdpr`). @@ -87,6 +111,8 @@ pub struct RegsExt { impl ToExt for RegsExt {} +/// The `ext.trusted_server` object the driver writes on every request it +/// signs, and on an unsigned one whose source keeps the request identity. #[derive(Debug, Serialize)] pub struct TrustedServerExt { /// Version of the signing protocol (e.g., "1.1") @@ -105,99 +131,9 @@ pub struct TrustedServerExt { pub ts: Option, } -#[derive(Debug, Serialize)] -pub struct BidExt<'a> { - pub trusted_server: BidTrustedServerExt<'a>, -} - -impl ToExt for BidExt<'_> {} - -#[derive(Debug, Serialize)] -pub struct BidTrustedServerExt<'a> { - pub renderer: &'a BidRenderer, -} - -#[derive(Debug, Serialize)] -pub struct ResponseExt { - pub orchestrator: OrchestratorExt, -} - -impl ToExt for ResponseExt {} - #[cfg(test)] mod tests { use super::*; - use crate::auction::types::OrchestratorExt; - - #[test] - fn openrtb_response_round_trips_with_struct_literals() { - let bid = OpenRtbBid { - id: Some("bidder-a-slot-1".to_owned()), - impid: Some("slot-1".to_owned()), - price: Some(1.25), - adm: Some("
Test Creative HTML
".to_owned()), - crid: Some("bidder-a-creative".to_owned()), - w: Some(300), - h: Some(250), - adomain: vec!["example.com".to_owned()], - ..Default::default() - }; - - let seatbid = SeatBid { - seat: Some("bidder-a".to_owned()), - bid: vec![bid], - ..Default::default() - }; - - let ext = ResponseExt { - orchestrator: OrchestratorExt { - strategy: "parallel_only".to_owned(), - providers: 2, - total_bids: 3, - time_ms: 12, - provider_details: vec![], - dropped_winner_count: 0, - dropped_winner_reasons: Default::default(), - }, - } - .to_ext(); - - let response = OpenRtbResponse { - id: Some("auction-1".to_owned()), - seatbid: vec![seatbid], - ext, - ..Default::default() - }; - - let serialized = serde_json::to_value(&response).expect("should serialize"); - let expected = serde_json::json!({ - "id": "auction-1", - "seatbid": [{ - "seat": "bidder-a", - "bid": [{ - "id": "bidder-a-slot-1", - "impid": "slot-1", - "price": 1.25, - "adm": "
Test Creative HTML
", - "crid": "bidder-a-creative", - "w": 300, - "h": 250, - "adomain": ["example.com"] - }] - }], - "ext": { - "orchestrator": { - "strategy": "parallel_only", - "providers": 2, - "total_bids": 3, - "time_ms": 12, - "provider_details": [] - } - } - }); - - assert_eq!(serialized, expected); - } #[test] fn regs_serializes_dual_placement_consent_fields() { @@ -222,7 +158,6 @@ mod tests { }; let serialized = serde_json::to_value(®s).expect("should serialize"); - // Top-level fields assert_eq!(serialized["gdpr"], 1, "top-level gdpr should be 1"); assert_eq!( serialized["us_privacy"], "1YNN", @@ -237,7 +172,6 @@ mod tests { serde_json::json!([2, 6]), "top-level gpp_sid should match" ); - // ext-based fields (Prebid reads these) let ext = &serialized["ext"]; assert_eq!(ext["gdpr"], 1, "ext gdpr should mirror top-level"); assert_eq!( @@ -324,4 +258,26 @@ mod tests { "consent should be omitted when None" ); } + + #[test] + fn identifiers_are_formatted_as_eids_with_their_agent_type() { + let eids = format_eids(&[trusted_server_core::ec::eids::Eid { + source: "google.com".to_owned(), + uids: vec![trusted_server_core::ec::eids::Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: Some(serde_json::json!({"linkType": 1})), + }], + }]); + + let serialized = serde_json::to_value(&eids).expect("should serialize"); + assert_eq!( + serialized, + serde_json::json!([{ + "source": "google.com", + "uids": [{"id": "pair-id", "atype": 571187, "ext": {"linkType": 1}}] + }]), + "should carry the source, the identifier, the vendor's agent type and the extension" + ); + } } diff --git a/crates/auction/aps/Cargo.toml b/crates/auction/aps/Cargo.toml index 7156f62a3e..94f90247bc 100644 --- a/crates/auction/aps/Cargo.toml +++ b/crates/auction/aps/Cargo.toml @@ -22,6 +22,7 @@ http = { workspace = true } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +trusted-server-auction-protocol-openrtb = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } validator = { workspace = true } @@ -29,6 +30,7 @@ validator = { workspace = true } [dev-dependencies] futures = { workspace = true } tokio = { workspace = true } +trusted-server-auction-protocol-openrtb = { workspace = true, features = ["test-utils"] } trusted-server-core = { workspace = true, features = ["test-utils"] } # The visible owner of this vendor crate, the way Prebid.js requires a named diff --git a/crates/auction/aps/src/lib.rs b/crates/auction/aps/src/lib.rs index 3d7b8b11f1..b9545b5540 100644 --- a/crates/auction/aps/src/lib.rs +++ b/crates/auction/aps/src/lib.rs @@ -31,12 +31,23 @@ use url::Url; use validator::Validate; use validator::ValidationError; +use trusted_server_auction_protocol_openrtb::demand::{ + CONSERVATIVE_LANGUAGE_MAX_BYTES, DemandFieldPolicy, OpenRtbDemand, OpenRtbDriver, RegsPolicy, + RequestExtensions, +}; +#[cfg(test)] +use trusted_server_auction_protocol_openrtb::wire::ToExt; +#[cfg(test)] +use trusted_server_auction_protocol_openrtb::wire::{ + Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, User, + UserExt, to_openrtb_i32, +}; use trusted_server_core::auction::demand::{ - CONSERVATIVE_LANGUAGE_MAX_BYTES, CompiledDemand, DemandFieldPolicy, DemandImplementation, - DemandResponse, DemandTimeoutDefault, ProviderAuctionInput, RegsPolicy, RequestExtensions, + CompiledDemand, DemandImplementation, DemandResponse, DemandTimeoutDefault, + ProviderAuctionInput, }; -use trusted_server_core::auction::openrtb::ignored_bidder_params_count; use trusted_server_core::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; +use trusted_server_core::auction::provider::ignored_bidder_params_count; #[cfg(test)] use trusted_server_core::auction::provider::{AuctionProvider, ProviderRequestOutcome}; #[cfg(test)] @@ -51,13 +62,6 @@ use trusted_server_core::integrations::{ use trusted_server_core::integrations::{ ensure_integration_backend_with_timeout, predict_integration_backend_name, }; -#[cfg(test)] -use trusted_server_core::openrtb::ToExt; -#[cfg(test)] -use trusted_server_core::openrtb::{ - Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, User, - UserExt, to_openrtb_i32, -}; use trusted_server_core::platform::PlatformResponse; #[cfg(test)] use trusted_server_core::platform::{PlatformHttpRequest, RuntimeServices}; @@ -506,9 +510,9 @@ fn check_aps_endpoint(endpoint: &mut Url) -> Result<(), String> { fn compile_demand( settings: &serde_json::Map, ) -> Result, Report> { - Ok(Arc::new(compile_aps_settings(Json::Object( - settings.clone(), - ))?)) + Ok(Arc::new(OpenRtbDriver::new(compile_aps_settings( + Json::Object(settings.clone()), + )?))) } /// Parse and validate the settings of one APS demand source. @@ -559,7 +563,7 @@ pub(crate) fn compile_aps_settings(value: Json) -> Result DemandFieldPolicy { DemandFieldPolicy { primary_banner_size: true, @@ -3607,12 +3611,12 @@ mod engine_tests { use base64::Engine as _; + use trusted_server_auction_protocol_openrtb::test_support::build_for_first_source; use trusted_server_core::auction::orchestrator::AuctionOrchestrator; use trusted_server_core::auction::plan::{AuctionPlan, AuctionPlanConfig, NotificationConfig}; use trusted_server_core::auction::test_support::{ - build_for_first_source, canonical_parity_auction_request, demand_table, - deterministic_signer, golden_inbound_request, golden_plan_config, parse_as_first_source, - plan_config_with, + canonical_parity_auction_request, demand_table, deterministic_signer, + golden_inbound_request, golden_plan_config, parse_as_first_source, plan_config_with, }; use trusted_server_core::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, MediaType, PublisherInfo, @@ -3816,7 +3820,7 @@ mod engine_tests { fn golden_request( settings: serde_json::Value, signer: Option<&trusted_server_core::request_signing::RequestSigner>, - ) -> trusted_server_core::openrtb::OpenRtbRequest { + ) -> trusted_server_auction_protocol_openrtb::wire::OpenRtbRequest { let plan = AuctionPlan::compile(golden_plan_config(MODULE, settings, true, &[builder()])) .expect("should compile plan"); build_for_first_source( @@ -3835,7 +3839,7 @@ mod engine_tests { settings: serde_json::Value, request: AuctionRequest, accept_language: Option<&str>, - ) -> trusted_server_core::openrtb::OpenRtbRequest { + ) -> trusted_server_auction_protocol_openrtb::wire::OpenRtbRequest { let plan = AuctionPlan::compile(golden_plan_config(MODULE, settings, true, &[builder()])) .expect("should compile plan"); let mut inbound = http::Request::builder().uri("https://publisher.example/auction"); diff --git a/crates/auction/prebid-server/Cargo.toml b/crates/auction/prebid-server/Cargo.toml index c1bfa1cefd..5ffa8fdd69 100644 --- a/crates/auction/prebid-server/Cargo.toml +++ b/crates/auction/prebid-server/Cargo.toml @@ -20,6 +20,7 @@ http = { workspace = true } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +trusted-server-auction-protocol-openrtb = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } @@ -31,6 +32,7 @@ futures = { workspace = true } tokio = { workspace = true } validator = { workspace = true } web-time = { workspace = true } +trusted-server-auction-protocol-openrtb = { workspace = true, features = ["test-utils"] } trusted-server-core = { workspace = true, features = ["test-utils"] } # The visible owner of this vendor crate, the way Prebid.js requires a named diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index 540c40d6bc..19534e152d 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -40,14 +40,22 @@ use url::{Url, Url as ParsedUrl}; #[cfg(test)] use validator::Validate; -use trusted_server_core::auction::demand::{ - CompiledDemand, DemandFieldPolicy, DemandImplementation, DemandResponse, DemandTimeoutDefault, - DemandTransport, ProviderAuctionInput, RegsPolicy, RequestExtensions, TransportHeaders, +use trusted_server_auction_protocol_openrtb::demand::{ + DemandFieldPolicy, OpenRtbDemand, OpenRtbDriver, RegsPolicy, RequestExtensions, +}; +#[cfg(test)] +use trusted_server_auction_protocol_openrtb::wire::{ + Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, + RegsExt, Site, ToExt, TrustedServerExt, User, UserExt, to_openrtb_i32, }; -use trusted_server_core::auction::openrtb::{ +use trusted_server_core::auction::admission::{ BidDimensionIndex, BidRejectionReason, ResponseAdmissionDiagnostics, build_bid_dimension_index, parse_optional_bid_dimension, resolve_bid_dimensions, }; +use trusted_server_core::auction::demand::{ + CompiledDemand, DemandImplementation, DemandResponse, DemandTimeoutDefault, DemandTransport, + ProviderAuctionInput, TransportHeaders, +}; use trusted_server_core::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; #[cfg(test)] use trusted_server_core::auction::provider::{AuctionProvider, ProviderRequestOutcome}; @@ -68,11 +76,6 @@ use trusted_server_core::integrations::{ ensure_integration_backend_with_timeout, predict_integration_backend_name, }; #[cfg(test)] -use trusted_server_core::openrtb::{ - Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, - RegsExt, Site, ToExt, TrustedServerExt, User, UserExt, to_openrtb_i32, -}; -#[cfg(test)] use trusted_server_core::platform::PlatformHttpRequest; use trusted_server_core::platform::PlatformResponse; #[cfg(test)] @@ -159,13 +162,13 @@ fn compile( &settings.bid_param_overrides, &settings.bid_param_override_rules, )?; - Ok(Arc::new(PrebidServerDemand { + Ok(Arc::new(OpenRtbDriver::new(PrebidServerDemand { debug: settings.debug, test_mode: settings.test_mode, debug_query_params: settings.debug_query_params, override_engine, consent_forwarding: settings.consent_forwarding, - })) + }))) } /// Complete an endpoint that names the host alone with the auction path. @@ -178,7 +181,7 @@ fn canonicalize_endpoint(endpoint: &mut Url) -> Result<(), String> { } #[async_trait(?Send)] -impl CompiledDemand for PrebidServerDemand { +impl OpenRtbDemand for PrebidServerDemand { fn field_policy(&self) -> DemandFieldPolicy { DemandFieldPolicy { imp_tagid: true, @@ -6430,7 +6433,7 @@ set = { networkId = 42 } assert!(bid.returned_seat.is_none()); } - trusted_server_core::auction::openrtb::apply_notification_policy( + trusted_server_core::auction::provider::apply_notification_policy( &mut parsed.bids, &trusted_server_core::auction::plan::NotificationPolicy { suppress_all: false, @@ -7307,19 +7310,19 @@ mod engine_tests { use edgezero_core::body::Body as EdgeBody; use http::{Request, header}; use serde_json::{Value, json}; + use trusted_server_auction_protocol_openrtb::test_support::build_for_first_source; + use trusted_server_auction_protocol_openrtb::wire::OpenRtbRequest; use trusted_server_core::auction::orchestrator::AuctionOrchestrator; use trusted_server_core::auction::plan::{AuctionPlan, AuctionPlanConfig, NotificationConfig}; use trusted_server_core::auction::test_support::{ - build_for_first_source, canonical_parity_auction_request, demand_table, - deterministic_signer, golden_inbound_request, golden_plan_config, plan_config_with, - route_to_first_source, + canonical_parity_auction_request, demand_table, deterministic_signer, + golden_inbound_request, golden_plan_config, plan_config_with, route_to_first_source, }; use trusted_server_core::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, MediaType, PublisherInfo, UserInfo, }; use trusted_server_core::consent::jurisdiction::Jurisdiction; use trusted_server_core::consent::{ConsentContext, ConsentSource}; - use trusted_server_core::openrtb::OpenRtbRequest; use trusted_server_core::platform::BackendNamingPolicy; use trusted_server_core::platform::test_support::{ NamingBackend, StubHttpClient, build_services_with_backend_and_http_client, diff --git a/crates/trusted-server-core/src/auction/admission.rs b/crates/trusted-server-core/src/auction/admission.rs new file mode 100644 index 0000000000..12cdc43d35 --- /dev/null +++ b/crates/trusted-server-core/src/auction/admission.rs @@ -0,0 +1,361 @@ +//! Admission of an upstream bid against the slots the request asked for. +//! +//! A bid is admitted when it names a slot the request carried and its +//! dimensions match exactly one format that slot asked for. These rules are +//! core's, so a source's response reader in any crate applies them, whatever +//! protocol it reads. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value, json}; + +use super::routing::{ProviderAuctionInput, ProviderSlotInput}; +use super::types::{AdFormat, AdSlot, AuctionResponse}; + +/// Fixed reasons why an upstream bid failed admission. +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +pub enum BidRejectionReason { + /// The bid is malformed. + InvalidBid, + /// The bid names a slot the request did not carry. + UnrequestedImpression, + /// The bid's dimensions match no format the slot asked for. + DimensionMismatch, + /// The bid's dimensions match more than one format the slot asked for. + 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 struct ResponseAdmissionDiagnostics { + rejected_bid_count: u32, + reason_counts: BTreeMap, +} + +impl ResponseAdmissionDiagnostics { + /// Record one rejected bid without retaining upstream payload data. + pub 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 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, + }), + ); + } +} + +#[derive(Clone, Copy)] +enum DimensionMatch { + Unique((u32, u32)), + Ambiguous, +} + +impl DimensionMatch { + fn include(&mut self, dimensions: (u32, u32)) { + if matches!(self, Self::Unique(existing) if *existing != dimensions) { + *self = Self::Ambiguous; + } + } + + fn resolve(self) -> Result<(u32, u32), BidRejectionReason> { + match self { + Self::Unique(dimensions) => Ok(dimensions), + Self::Ambiguous => Err(BidRejectionReason::AmbiguousDimensions), + } + } +} + +/// Precomputed dimension resolutions for one requested slot. +pub struct SlotBidDimensions { + exact: BTreeSet<(u32, u32)>, + by_width: BTreeMap, + by_height: BTreeMap, + unqualified: Option, +} + +impl SlotBidDimensions { + fn from_formats(formats: &[AdFormat]) -> Self { + let mut exact = BTreeSet::new(); + let mut by_width = BTreeMap::new(); + let mut by_height = BTreeMap::new(); + let mut unqualified: Option = None; + + for format in formats { + let dimensions = (format.width, format.height); + if !exact.insert(dimensions) { + continue; + } + by_width + .entry(format.width) + .and_modify(|candidate: &mut DimensionMatch| candidate.include(dimensions)) + .or_insert(DimensionMatch::Unique(dimensions)); + by_height + .entry(format.height) + .and_modify(|candidate: &mut DimensionMatch| candidate.include(dimensions)) + .or_insert(DimensionMatch::Unique(dimensions)); + match &mut unqualified { + Some(candidate) => candidate.include(dimensions), + None => unqualified = Some(DimensionMatch::Unique(dimensions)), + } + } + + Self { + exact, + by_width, + by_height, + unqualified, + } + } +} + +/// Precomputed requested banner dimensions keyed by slot id. +pub type BidDimensionIndex = BTreeMap; + +/// Build the requested-dimension index once for one provider response. +#[must_use] +pub fn build_bid_dimension_index(input: &ProviderAuctionInput) -> BidDimensionIndex { + build_bid_dimension_index_from_slots(input.slots().iter().map(ProviderSlotInput::slot)) +} + +/// Build the requested-dimension index from plain [`AdSlot`]s. +/// +/// The first slot wins when several share an id. +#[must_use] +pub fn build_bid_dimension_index_from_slots<'a>( + slots: impl IntoIterator, +) -> BidDimensionIndex { + let mut index = BidDimensionIndex::new(); + for slot in slots { + index + .entry(slot.id.clone()) + .or_insert_with(|| SlotBidDimensions::from_formats(slot.formats.as_slice())); + } + index +} + +/// Parse an optional positive bid dimension from a JSON field. +/// +/// # Errors +/// +/// Returns [`BidRejectionReason::InvalidBid`] when the value is present and is +/// not a positive whole number that fits. +pub fn parse_optional_bid_dimension( + value: &Value, + key: &str, +) -> Result, BidRejectionReason> { + let Some(raw) = value.get(key) else { + return Ok(None); + }; + if raw.is_null() { + return Ok(None); + } + + let dimension = raw + .as_u64() + .and_then(|dimension| u32::try_from(dimension).ok()) + .or_else(|| { + let dimension = raw.as_f64()?; + (dimension.is_finite() + && dimension > 0.0 + && dimension.fract() == 0.0 + && dimension <= f64::from(u32::MAX)) + .then_some(dimension as u32) + }) + .filter(|dimension| *dimension > 0) + .ok_or(BidRejectionReason::InvalidBid)?; + Ok(Some(dimension)) +} + +/// Validate explicit dimensions or infer them from one matching banner format. +/// +/// # Errors +/// +/// Returns the reason the bid is refused: the slot was not requested, or the +/// dimensions match none or more than one of its formats. +pub fn resolve_bid_dimensions( + dimensions_by_slot: &BidDimensionIndex, + slot_id: &str, + width: Option, + height: Option, +) -> Result<(u32, u32), BidRejectionReason> { + let dimensions = dimensions_by_slot + .get(slot_id) + .ok_or(BidRejectionReason::UnrequestedImpression)?; + + match (width, height) { + (Some(width), Some(height)) => dimensions + .exact + .contains(&(width, height)) + .then_some((width, height)) + .ok_or(BidRejectionReason::DimensionMismatch), + (Some(width), None) => dimensions + .by_width + .get(&width) + .copied() + .ok_or(BidRejectionReason::DimensionMismatch)? + .resolve(), + (None, Some(height)) => dimensions + .by_height + .get(&height) + .copied() + .ok_or(BidRejectionReason::DimensionMismatch)? + .resolve(), + (None, None) => dimensions + .unqualified + .ok_or(BidRejectionReason::DimensionMismatch)? + .resolve(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + + use super::*; + use crate::auction::types::MediaType; + + fn slot(id: &str, formats: &[(u32, u32)]) -> AdSlot { + AdSlot { + id: id.to_string(), + formats: formats + .iter() + .map(|&(width, height)| AdFormat { + media_type: MediaType::Banner, + width, + height, + }) + .collect(), + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + } + } + + #[test] + fn dimensions_are_admitted_exactly_inferred_uniquely_and_refused_otherwise() { + let index = build_bid_dimension_index_from_slots(&[slot( + "one", + &[(300, 250), (300, 600), (320, 50)], + )]); + + assert_eq!( + resolve_bid_dimensions(&index, "one", Some(300), Some(250)), + Ok((300, 250)), + "an exact match is admitted" + ); + assert_eq!( + resolve_bid_dimensions(&index, "one", None, Some(50)), + Ok((320, 50)), + "a unique height infers the width" + ); + assert_eq!( + resolve_bid_dimensions(&index, "one", Some(300), None), + Err(BidRejectionReason::AmbiguousDimensions), + "a width two formats share is ambiguous" + ); + assert_eq!( + resolve_bid_dimensions(&index, "one", None, None), + Err(BidRejectionReason::AmbiguousDimensions), + "no dimensions against several formats is ambiguous" + ); + assert_eq!( + resolve_bid_dimensions(&index, "one", Some(999), Some(1)), + Err(BidRejectionReason::DimensionMismatch), + "dimensions no format asked for are refused" + ); + assert_eq!( + resolve_bid_dimensions(&index, "other", Some(300), Some(250)), + Err(BidRejectionReason::UnrequestedImpression), + "a slot the request did not carry is refused" + ); + } + + #[test] + fn the_first_slot_wins_when_several_share_an_id() { + let index = build_bid_dimension_index_from_slots(&[ + slot("one", &[(300, 250)]), + slot("one", &[(728, 90)]), + ]); + assert_eq!( + resolve_bid_dimensions(&index, "one", None, None), + Ok((300, 250)) + ); + } + + #[test] + fn a_dimension_is_a_positive_whole_number_or_absent() { + for (value, expected) in [ + (json!({}), Ok(None)), + (json!({"w": null}), Ok(None)), + (json!({"w": 300}), Ok(Some(300))), + (json!({"w": 300.0}), Ok(Some(300))), + (json!({"w": 300.5}), Err(BidRejectionReason::InvalidBid)), + (json!({"w": 0}), Err(BidRejectionReason::InvalidBid)), + (json!({"w": -1}), Err(BidRejectionReason::InvalidBid)), + ( + json!({"w": 4_294_967_296.0}), + Err(BidRejectionReason::InvalidBid), + ), + (json!({"w": "300"}), Err(BidRejectionReason::InvalidBid)), + ] { + assert_eq!( + parse_optional_bid_dimension(&value, "w"), + expected, + "{value}" + ); + } + } + + #[test] + fn rejections_are_counted_by_reason_and_attached_only_when_there_are_any() { + let mut none = AuctionResponse::no_bid("source", 0); + ResponseAdmissionDiagnostics::default().attach_to(&mut none); + assert!( + !none.metadata.contains_key("response_admission"), + "nothing rejected writes no diagnostics" + ); + + let mut diagnostics = ResponseAdmissionDiagnostics::default(); + diagnostics.record(BidRejectionReason::InvalidBid); + diagnostics.record(BidRejectionReason::InvalidBid); + diagnostics.record(BidRejectionReason::DimensionMismatch); + let mut some = AuctionResponse::no_bid("source", 0); + diagnostics.attach_to(&mut some); + assert_eq!( + some.metadata["response_admission"], + json!({ + "rejected_bid_count": 3, + "rejection_reasons": {"dimension_mismatch": 1, "invalid_bid": 2}, + }) + ); + } +} diff --git a/crates/trusted-server-core/src/auction/demand.rs b/crates/trusted-server-core/src/auction/demand.rs index c9800bb36c..6738d01371 100644 --- a/crates/trusted-server-core/src/auction/demand.rs +++ b/crates/trusted-server-core/src/auction/demand.rs @@ -4,12 +4,13 @@ //! A deployment selects its demand sources with `[demand] modules = [...]` and //! its ad server with `[ad-server] module = "..."`. Each selected name resolves //! to an implementation that an integration builder registers, so core names no -//! vendor. The common `OpenRTB` driver builds every standard request field, sends -//! the request and enforces privacy. An implementation decides only the choices -//! this module exposes, which are its field policy, its own extensions, its -//! outbound headers and how its responses are read. +//! vendor and no protocol. Core routes each source the facts it may use, sends +//! the request the source builds from them and hands the answer back to the +//! source to read. The protocol a source speaks is the source's own, through +//! the crate of the standard it speaks. use core::any::Any; +use std::collections::HashSet; use std::net::IpAddr; use std::sync::Arc; @@ -19,19 +20,16 @@ use http::HeaderMap; use serde_json::{Map, Value}; use url::Url; -use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::platform::PlatformResponse; +use crate::request_signing::{RequestSigner, SigningParams}; use super::provider::AuctionProvider; -pub use super::routing::{ProviderAuctionInput, ProviderSlotInput, TransportHeaders}; +pub use super::routing::{ + ProviderAuctionInput, ProviderSlotInput, RoutedAuction, TransportHeaders, +}; use super::types::AuctionResponse; -/// The longest primary `Accept-Language` tag a conservative source receives. -/// -/// A source that sets no limit receives the whole primary tag. -pub const CONSERVATIVE_LANGUAGE_MAX_BYTES: usize = 8; - /// Where a demand source's timeout comes from when its table sets none. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DemandTimeoutDefault { @@ -41,160 +39,78 @@ pub enum DemandTimeoutDefault { Fixed(u32), } -/// How the `regs` object is derived from the consent the auction admitted. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum RegsPolicy { - /// No `regs` without consent data, and GDPR set from the applicability bit - /// or from a GDPR jurisdiction. - #[default] - Jurisdiction, - /// `regs` for any admitted consent, and GDPR set from the applicability bit - /// alone. - ApplicabilityBit, -} - -/// The standard `OpenRTB` field choices one demand source makes. -/// -/// The common driver constructs every standard field and reads this policy -/// while it does. A policy can leave data out of what central privacy -/// enforcement approved, and it cannot add data that enforcement removed. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct DemandFieldPolicy { - /// Send the slot id as `imp.tagid`. - pub imp_tagid: bool, - /// Copy the first banner format into `banner.w` and `banner.h`, and send - /// `banner.topframe` as not in the top frame. - pub primary_banner_size: bool, - /// Send the accepted `Referer` header as `site.ref`. - pub site_ref: bool, - /// Send precise latitude and longitude with the coarse geo. - pub precise_geo: bool, - /// Send Google Additional Consent as `user.ext.ConsentedProvidersSettings`. - pub additional_consent: bool, - /// The longest primary `Accept-Language` tag sent, or `None` for no limit. - pub language_max_bytes: Option, - /// How `regs` is derived from consent. - pub regs: RegsPolicy, - /// Mark the request as a test request. - pub test: bool, - /// Keep the Trusted Server host and scheme in `ext.trusted_server` when the - /// request is not signed. - pub unsigned_request_identity: bool, - /// Send `Accept: application/json`. - pub accept_json: bool, - /// Treat routed bidder parameters as request data rather than reporting - /// them as parameters the source ignored. - pub consumes_bidder_params: bool, -} - -/// Facts an implementation may use when it adjusts the outbound request. +/// The request facts a source may use beside its routed input. #[derive(Debug, Clone, Copy)] pub struct DemandTransport<'a> { /// The request headers admitted for forwarding. pub headers: &'a TransportHeaders, /// The client address the platform attested, never one a client supplied. pub attested_client_ip: Option, + /// Whether the request asked not to be tracked. + pub dnt: Option, } -/// Facts an implementation reads while it parses a response. +/// The signing input core settles before a source builds its request. No +/// signer is loaded here. +pub struct RequestFinalization<'a> { + /// The signer the auction admitted, or `None` for an unsigned request. + pub signer: Option<&'a RequestSigner>, + /// What the signature is made over. + pub signing_params: SigningParams, +} + +/// The request one source built from its routed input, ready to send. +pub struct OutboundRequest { + /// The body as it is sent. + pub body: Vec, + /// The headers the source sets, `Content-Type` included. + pub headers: HeaderMap, + /// The ids of the slots the request asks for. The response is read + /// against these slots alone. + pub slot_ids: HashSet, + /// State the source keeps from the request for reading the response. + pub captured: Option>, +} + +/// Facts a source reads while it parses a response. #[derive(Clone, Copy)] pub struct DemandResponse<'a> { /// The configured name of the demand source. pub provider_id: &'a str, /// The canonical endpoint the request was sent to. pub endpoint: &'a str, - /// The routed input the request was built from. + /// The routed input the request was built from, with the slots the + /// request asked for. pub input: &'a ProviderAuctionInput, /// How long the request took, in milliseconds. pub response_time_ms: u64, - /// State [`CompiledDemand::capture_request`] returned for this request. + /// State the source kept in [`OutboundRequest::captured`]. pub captured: Option<&'a (dyn Any + Send + Sync)>, } -/// The extension objects an implementation may add to a request the driver -/// built, one for the request and one for each impression. -/// -/// This is the only part of the request an implementation writes. Every -/// standard field is the driver's, chosen through [`DemandFieldPolicy`], so an -/// implementation can add what its exchange needs and cannot change what -/// privacy enforcement decided. The `trusted_server` request extension is the -/// driver's too, and a request that claims it is refused. -pub struct RequestExtensions<'a> { - /// The request-level `ext` object. - pub request: &'a mut Option>, - /// The impressions the driver built, each beside the slot it came from. - pub impressions: Vec>, -} - -/// One impression's `ext` object beside the routed slot it was built from. -pub struct ImpressionExtension<'a> { - /// The routed slot, with its bidder parameters and zone. - pub slot: &'a ProviderSlotInput, - /// The impression's `ext` object. - pub ext: &'a mut Option>, - /// Set by an implementation that has no demand to send for this slot. - /// The driver leaves the impression out of the request, and makes no - /// request at all when every impression is left out. - pub omitted: bool, -} - /// The request and response behavior of one selected demand source, compiled /// from its `[demand.]` table at startup. /// -/// Every method except [`augment_request`](Self::augment_request) and -/// [`parse_response`](Self::parse_response) has a default that suits a source -/// with no special handling. +/// A source builds its request from the facts core routed to it, which are +/// the slots and their formats, the publisher, the permission state the +/// auction resolved and the identifiers the request may carry, and reads the +/// answer into core's bids. What the request looks like on the wire is the +/// source's own, through the crate of the protocol it speaks. #[async_trait(?Send)] pub trait CompiledDemand: Send + Sync { - /// The standard field choices this source makes. - fn field_policy(&self) -> DemandFieldPolicy; - - /// The domain sent as `site.domain` and `site.publisher.domain`. - fn site_domain(&self, publisher_domain: &str) -> String { - publisher_domain.to_owned() - } - - /// The page sent as `site.page`, given the publisher's page and the site - /// domain [`site_domain`](Self::site_domain) chose. - fn site_page(&self, publisher_page: Option<&str>, site_domain: &str) -> Option { - let _ = site_domain; - publisher_page.map(str::to_owned) - } - - /// The consent that may travel in the request body. - fn body_consent<'c>(&self, consent: Option<&'c ConsentContext>) -> Option<&'c ConsentContext> { - consent - } - - /// Adds this source's own request and impression extensions. + /// Builds the request for the routed input, or `None` when the source has + /// nothing to ask for, in which case core makes no request. /// /// # Errors /// - /// Returns an error when an extension cannot be built for this request. - fn augment_request( + /// Returns an error when the request cannot be built for this input. + fn build_request( &self, - extensions: &mut RequestExtensions<'_>, input: &ProviderAuctionInput, - ) -> Result<(), Report>; - - /// Adds to the outbound headers before the request is sent, for example to - /// forward admitted headers. The driver has already set `Content-Type`, - /// and `Accept` when the field policy asks for it. - fn prepare_outbound(&self, headers: &mut HeaderMap, transport: DemandTransport<'_>) { - let _ = (headers, transport); - } - - /// Keeps request-local state the response parser needs, taken from the - /// serialized body and the outbound headers as they will be sent, after - /// [`prepare_outbound`](Self::prepare_outbound) has run. - fn capture_request( - &self, - body: &[u8], - headers: &HeaderMap, - ) -> Option> { - let _ = (body, headers); - None - } + transport: DemandTransport<'_>, + timeout_ms: u32, + finalization: &RequestFinalization<'_>, + ) -> Result, Report>; /// Reads the upstream response into an auction response. /// @@ -209,6 +125,12 @@ pub trait CompiledDemand: Send + Sync { response: PlatformResponse, ) -> Result>; + /// Whether routed bidder parameters are request data for this source, + /// rather than parameters it was routed and ignores. + fn consumes_bidder_params(&self) -> bool { + false + } + /// This compiled source as [`Any`], so the implementation that created it /// can read its own settings back, for example while it registers routes. fn as_any(&self) -> &dyn Any; @@ -217,8 +139,8 @@ pub trait CompiledDemand: Send + Sync { /// Compiles one demand source from its settings. /// /// The map holds the table's own settings, without `implementation` and the -/// common keys the driver reads, which are `endpoint`, `timeout_ms`, `routing` -/// and `notifications`. +/// common keys core reads, which are `endpoint`, `timeout_ms`, `routing` and +/// `notifications`. pub type CompileDemandFn = fn(&Map) -> Result, Report>; diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index ca409f2f87..e7303686eb 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -2,14 +2,17 @@ //! //! This module handles: //! - Parsing incoming tsjs/Prebid.js format requests -//! - Converting internal auction results to `OpenRTB` 2.x responses +//! - Converting internal auction results to the `OpenRTB` 2.x response the +//! auction endpoint returns, which is the one protocol-shaped step core +//! still holds use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt, ensure}; use http::{HeaderValue, Request, Response, StatusCode, header}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::{BTreeMap, HashMap, HashSet}; +use trusted_server_openrtb::{Bid as OpenRtbBid, BidResponse as OpenRtbResponse, SeatBid, ToExt}; use url::Url; use uuid::Uuid; @@ -21,10 +24,6 @@ use crate::ec::eids::encode_eids_header; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::module_context::{ModuleContext, ModuleRequest}; -use crate::openrtb::{ - BidExt, BidTrustedServerExt, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt, - to_openrtb_i32, -}; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -49,8 +48,8 @@ pub struct AdRequest { /// A single ad placement in an [`AdRequest`]. /// -/// `code` identifies the slot (e.g. `"atf_sidebar_ad"`) and becomes the -/// impression ID in the outgoing `OpenRTB` request. +/// `code` identifies the slot (e.g. `"atf_sidebar_ad"`) and is the slot id +/// every demand source is routed. /// /// `bids` is optional. Absent or empty bids retain legacy PBS stored fallback /// keyed by `code` (`imp.ext.prebid.storedrequest.id`). Bidder params route through @@ -155,8 +154,7 @@ fn publisher_page_url(req: &Request, publisher_domain: &str) -> String /// /// The `consent` parameter carries decoded consent signals extracted from the /// incoming request's cookies and headers. It is populated by the caller -/// (the `/auction` endpoint handler) and forwarded through to the -/// [`OpenRTB`][`crate::openrtb::OpenRtbRequest`] bid request. +/// (the `/auction` endpoint handler) and forwarded to every demand source. /// /// The `ec_id` is generated by the caller before the consent pipeline /// runs, so that KV Store operations can use it as a key. @@ -286,6 +284,40 @@ pub fn convert_tsjs_to_auction_request( }) } +/// The bid extension that carries a typed renderer to the page. +#[derive(Debug, Serialize)] +struct BidExt<'a> { + trusted_server: BidTrustedServerExt<'a>, +} + +impl ToExt for BidExt<'_> {} + +#[derive(Debug, Serialize)] +struct BidTrustedServerExt<'a> { + renderer: &'a BidRenderer, +} + +/// The response extension that carries the orchestrator's summary. +#[derive(Debug, Serialize)] +struct ResponseExt { + orchestrator: OrchestratorExt, +} + +impl ToExt for ResponseExt {} + +/// A dimension as the response carries it, or `None` with a log line when +/// the value does not fit. +fn response_dimension(value: u32, field_name: &str, context: &str) -> Option { + if let Ok(converted) = i32::try_from(value) { + Some(converted) + } else { + log::warn!( + "auction response: omitting {field_name}={value} for {context} because value exceeds i32::MAX" + ); + None + } +} + /// Delivery facts produced while serializing winning bids. #[derive(Debug, Default)] pub(crate) struct AuctionDeliveryReport { @@ -372,8 +404,8 @@ pub(crate) fn convert_to_openrtb_response_with_report( "auction {} slot {} bidder {}", auction_request.id, slot_id, bid.bidder ); - let width = to_openrtb_i32(bid.width, "width", &bid_context); - let height = to_openrtb_i32(bid.height, "height", &bid_context); + let width = response_dimension(bid.width, "width", &bid_context); + let height = response_dimension(bid.height, "height", &bid_context); // Ordinary markup goes through the configured creative processing: // sanitization is opt-in, rewriting is on by default, and with both diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 9cde111aed..4a8a83f792 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -3,7 +3,9 @@ //! //! `[demand] modules` selects the demand sources and `[ad-server] module` //! the optional ad server. Their implementations are registered by -//! integrations through [`demand`], so this module names no vendor. +//! integrations through [`demand`], so this module names no vendor and no +//! protocol. Each source builds its own request and reads its own answer, +//! and [`admission`] holds the rules every reader admits a bid by. use error_stack::Report; @@ -12,12 +14,12 @@ use crate::integrations::IntegrationBuilder; use crate::settings::Settings; use std::sync::Arc; +pub mod admission; pub mod config; pub mod context; pub mod demand; pub mod endpoints; pub mod formats; -pub mod openrtb; pub mod orchestrator; pub mod plan; pub mod provider; diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs deleted file mode 100644 index fdea467979..0000000000 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ /dev/null @@ -1,925 +0,0 @@ -//! The shared `OpenRTB` 2.6 request and response driver. -//! -//! Every demand source's request is built here from routed, privacy-approved -//! facts, never from the raw downstream request or unrestricted runtime -//! services. A demand implementation decides only what -//! [`crate::auction::demand`] exposes, and this driver builds the rest the same -//! way for all of them. - -use std::collections::{BTreeMap, BTreeSet, HashMap}; - -use error_stack::{Report, ResultExt as _}; -use http::StatusCode; -use serde_json::{Map, Value, json}; - -use super::demand::{ - CompiledDemand, DemandFieldPolicy, DemandResponse, ImpressionExtension, RegsPolicy, - RequestExtensions, -}; -use super::plan::{NotificationPolicy, ProviderPlan}; -use super::routing::{ProviderAuctionInput, ProviderSlotInput, RoutedAuction, TransportHeaders}; -use super::types::{AdFormat, AdSlot, AuctionResponse, Bid}; -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::platform::PlatformResponse; -use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; - -const DEFAULT_CURRENCY: &str = "USD"; -/// The request extension the driver writes and no implementation may claim. -const TRUSTED_SERVER_EXT_KEY: &str = "trusted_server"; - -/// Fixed reasons why an upstream bid failed response admission. -/// -/// Public, with the dimension helpers below, so an auction implementation in -/// a crate of its own admits a bid by the rules core's own response reader -/// applies. -#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] -pub enum BidRejectionReason { - /// The bid is malformed. - InvalidBid, - /// The bid names an impression the request did not carry. - UnrequestedImpression, - /// The bid's dimensions match no format the impression asked for. - DimensionMismatch, - /// The bid's dimensions match more than one format the impression asked - /// for. - 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 struct ResponseAdmissionDiagnostics { - rejected_bid_count: u32, - reason_counts: BTreeMap, -} - -impl ResponseAdmissionDiagnostics { - /// Record one rejected bid without retaining upstream payload data. - pub 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 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, - }), - ); - } -} - -#[derive(Clone, Copy)] -enum DimensionMatch { - Unique((u32, u32)), - Ambiguous, -} - -impl DimensionMatch { - fn include(&mut self, dimensions: (u32, u32)) { - if matches!(self, Self::Unique(existing) if *existing != dimensions) { - *self = Self::Ambiguous; - } - } - - fn resolve(self) -> Result<(u32, u32), BidRejectionReason> { - match self { - Self::Unique(dimensions) => Ok(dimensions), - Self::Ambiguous => Err(BidRejectionReason::AmbiguousDimensions), - } - } -} - -/// Precomputed dimension resolutions for one requested impression. -pub struct SlotBidDimensions { - exact: BTreeSet<(u32, u32)>, - by_width: BTreeMap, - by_height: BTreeMap, - unqualified: Option, -} - -impl SlotBidDimensions { - fn from_formats(formats: &[AdFormat]) -> Self { - let mut exact = BTreeSet::new(); - let mut by_width = BTreeMap::new(); - let mut by_height = BTreeMap::new(); - let mut unqualified: Option = None; - - for format in formats { - let dimensions = (format.width, format.height); - if !exact.insert(dimensions) { - continue; - } - by_width - .entry(format.width) - .and_modify(|candidate: &mut DimensionMatch| candidate.include(dimensions)) - .or_insert(DimensionMatch::Unique(dimensions)); - by_height - .entry(format.height) - .and_modify(|candidate: &mut DimensionMatch| candidate.include(dimensions)) - .or_insert(DimensionMatch::Unique(dimensions)); - match &mut unqualified { - Some(candidate) => candidate.include(dimensions), - None => unqualified = Some(DimensionMatch::Unique(dimensions)), - } - } - - Self { - exact, - by_width, - by_height, - unqualified, - } - } -} - -/// Precomputed requested banner dimensions keyed by impression ID. -pub type BidDimensionIndex = BTreeMap; - -/// Build the requested-dimension index once for one provider response. -#[must_use] -pub fn build_bid_dimension_index(input: &ProviderAuctionInput) -> BidDimensionIndex { - build_bid_dimension_index_from_slots(input.slots().iter().map(ProviderSlotInput::slot)) -} - -/// Build the requested-dimension index from plain [`AdSlot`]s. -/// -/// The first slot wins when several share an ID. -#[must_use] -pub fn build_bid_dimension_index_from_slots<'a>( - slots: impl IntoIterator, -) -> BidDimensionIndex { - let mut index = BidDimensionIndex::new(); - for slot in slots { - index - .entry(slot.id.clone()) - .or_insert_with(|| SlotBidDimensions::from_formats(slot.formats.as_slice())); - } - index -} - -/// Parse an optional positive `OpenRTB` bid dimension. -/// -/// # Errors -/// -/// Returns [`BidRejectionReason::InvalidBid`] when the value is present and is -/// not a positive whole number that fits. -pub fn parse_optional_bid_dimension( - value: &Value, - key: &str, -) -> Result, BidRejectionReason> { - let Some(raw) = value.get(key) else { - return Ok(None); - }; - if raw.is_null() { - return Ok(None); - } - - let dimension = raw - .as_u64() - .and_then(|dimension| u32::try_from(dimension).ok()) - .or_else(|| { - let dimension = raw.as_f64()?; - (dimension.is_finite() - && dimension > 0.0 - && dimension.fract() == 0.0 - && dimension <= f64::from(u32::MAX)) - .then_some(dimension as u32) - }) - .filter(|dimension| *dimension > 0) - .ok_or(BidRejectionReason::InvalidBid)?; - Ok(Some(dimension)) -} - -/// Validate explicit dimensions or infer them from one matching banner format. -/// -/// # Errors -/// -/// Returns the reason the bid is refused: the impression was not requested, -/// or the dimensions match none or more than one of its formats. -pub fn resolve_bid_dimensions( - dimensions_by_slot: &BidDimensionIndex, - slot_id: &str, - width: Option, - height: Option, -) -> Result<(u32, u32), BidRejectionReason> { - let dimensions = dimensions_by_slot - .get(slot_id) - .ok_or(BidRejectionReason::UnrequestedImpression)?; - - match (width, height) { - (Some(width), Some(height)) => dimensions - .exact - .contains(&(width, height)) - .then_some((width, height)) - .ok_or(BidRejectionReason::DimensionMismatch), - (Some(width), None) => dimensions - .by_width - .get(&width) - .copied() - .ok_or(BidRejectionReason::DimensionMismatch)? - .resolve(), - (None, Some(height)) => dimensions - .by_height - .get(&height) - .copied() - .ok_or(BidRejectionReason::DimensionMismatch)? - .resolve(), - (None, None) => dimensions - .unqualified - .ok_or(BidRejectionReason::DimensionMismatch)? - .resolve(), - } -} - -/// 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 demand source's request from its immutable routed input. -/// -/// # Errors -/// -/// Returns an auction error when an implementation's own extensions cannot be -/// built 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 demand = provider.demand.as_ref(); - let policy = demand.field_policy(); - let (mut request, slot_indices) = - build_common_request(input, routed, demand, policy, effective_timeout_ms); - if request.imp.is_empty() { - return Ok(OpenRtbBuildOutcome::NoImpressions); - } - let omitted = { - // The implementation sees the extension objects and the slot each - // impression came from, and nothing else of the request. - let OpenRtbRequest { ext, imp, .. } = &mut request; - let impressions = imp - .iter_mut() - .zip(&slot_indices) - .map(|(imp, &index)| ImpressionExtension { - slot: &input.slots()[index], - ext: &mut imp.ext, - omitted: false, - }) - .collect(); - let mut extensions = RequestExtensions { - request: ext, - impressions, - }; - demand.augment_request(&mut extensions, input)?; - extensions - .impressions - .iter() - .map(|impression| impression.omitted) - .collect::>() - }; - // An impression the implementation left without demand goes, and so does - // a request that has none left, so a demand source is never asked for - // nothing. - if omitted.iter().any(|&omitted| omitted) { - let mut omitted = omitted.into_iter(); - request.imp.retain(|_| !omitted.next().unwrap_or(false)); - } - if request.imp.is_empty() { - return Ok(OpenRtbBuildOutcome::NoImpressions); - } - if request - .ext - .as_ref() - .is_some_and(|ext| ext.contains_key(TRUSTED_SERVER_EXT_KEY)) - { - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "Provider {} set ext.{TRUSTED_SERVER_EXT_KEY}, which only the driver writes", - provider.id - ), - })); - } - finalize_request(&mut request, policy, finalization)?; - Ok(OpenRtbBuildOutcome::Ready(request)) -} - -/// Builds every standard field of the request. The second value holds, for -/// each impression built, the index of the routed slot it came from. -fn build_common_request( - input: &ProviderAuctionInput, - routed: &RoutedAuction, - demand: &dyn CompiledDemand, - policy: DemandFieldPolicy, - effective_timeout_ms: u32, -) -> (OpenRtbRequest, Vec) { - let common = input.common_request(); - let (slot_indices, imps): (Vec, Vec) = input - .slots() - .iter() - .enumerate() - .filter_map(|(index, slot)| build_imp(slot, policy).map(|imp| (index, imp))) - .unzip(); - let site_domain = demand.site_domain(&common.publisher.domain); - let page = demand.site_page(common.publisher.page_url.as_deref(), &site_domain); - let body_consent = demand.body_consent(common.user.consent.as_ref()); - 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: policy - .additional_consent - .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.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: policy.precise_geo.then_some(geo.latitude), - lon: policy.precise_geo.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(), - // Attributes the selected device module resolved, each absent - // when nothing resolved it. - devicetype: device.attributes.as_ref().and_then(|a| a.device_type), - make: device.attributes.as_ref().and_then(|a| a.make.clone()), - model: device.attributes.as_ref().and_then(|a| a.model.clone()), - os: device.attributes.as_ref().and_then(|a| a.os.clone()), - osv: device - .attributes - .as_ref() - .and_then(|a| a.os_version.clone()), - w: device.attributes.as_ref().and_then(|a| a.screen_width), - h: device.attributes.as_ref().and_then(|a| a.screen_height), - ..Default::default() - }) - .or_else(|| { - (routed.dnt().is_some() || language.is_some()).then_some(Device { - dnt: routed.dnt(), - language, - ..Default::default() - }) - }); - - let request = OpenRtbRequest { - id: Some(common.id.clone()), - imp: imps, - site: Some(Site { - domain: Some(site_domain.clone()), - page, - r#ref: policy - .site_ref - .then(|| header_string(routed.transport_headers().referer())) - .flatten(), - publisher: Some(Publisher { - domain: Some(site_domain), - ..Default::default() - }), - ..Default::default() - }), - user, - device, - regs: build_regs(body_consent, policy.regs), - test: policy.test.then_some(true), - tmax: to_openrtb_i32( - effective_timeout_ms, - "tmax", - "config-first provider request", - ), - cur: vec![DEFAULT_CURRENCY.to_string()], - ..Default::default() - }; - (request, slot_indices) -} - -fn build_imp(slot: &ProviderSlotInput, policy: DemandFieldPolicy) -> 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 primary_size = policy.primary_banner_size; - Some(Imp { - id: Some(slot.slot().id.clone()), - banner: Some(Banner { - format: formats, - w: primary_size.then_some(first_width).flatten(), - h: primary_size.then_some(first_height).flatten(), - topframe: primary_size.then_some(false), - ..Default::default() - }), - tagid: policy.imp_tagid.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 finalize_request( - request: &mut OpenRtbRequest, - policy: DemandFieldPolicy, - 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.unsigned_request_identity { - 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_EXT_KEY.to_string(), serialized); - } - Ok(()) -} - -fn build_regs( - consent: Option<&crate::consent::ConsentContext>, - policy: RegsPolicy, -) -> Option { - let consent = consent?; - if policy == RegsPolicy::ApplicabilityBit { - // Any admitted context produces regs and GDPR comes from the - // applicability bit alone, 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(), - }); - } - - // The jurisdiction policy sends no regs for an empty context and reads - // GDPR from the applicability bit or from a GDPR jurisdiction. - 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: &TransportHeaders, policy: DemandFieldPolicy) -> 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.language_max_bytes { - None => Some(value), - Some(max_bytes) => (value.len() <= max_bytes).then_some(value), - } -} - -fn header_string(value: Option<&http::HeaderValue>) -> Option { - value - .and_then(|value| value.to_str().ok()) - .map(str::to_string) -} - -/// Suppress notification URLs using exact returned-seat identity. -pub 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 dimensions_by_slot = build_bid_dimension_index(input); - 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, &dimensions_by_slot) { - 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>, - dimensions_by_slot: &BidDimensionIndex, -) -> 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(dimensions_by_slot, &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 demand source did not consume. -#[must_use] -pub(crate) fn unused_bidder_params_count( - demand: &dyn CompiledDemand, - input: &ProviderAuctionInput, -) -> u32 { - if demand.field_policy().consumes_bidder_params { - return 0; - } - ignored_bidder_params_count(input) -} - -/// Count routed bidder params for a demand source known to ignore them. -/// -/// Public so an implementation that takes no bidder parameters reports the -/// ones it was routed the way core's own reader does. -#[must_use] -pub 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)) - }) -} - -/// The most a demand source's response body may hold. -const MAX_RESPONSE_BYTES: usize = 1024 * 1024; - -/// Reads an ordinary `OpenRTB` response. A `204` is a no-bid, any other -/// non-success status or unreadable JSON is an error response, and a body over -/// the size limit is an `Err`. -/// -/// Public so a demand implementation in a crate of its own, whose exchange -/// answers the ordinary way, has its response read as core reads one. -/// -/// # Errors -/// -/// Returns an error when the response body cannot be read. -pub async fn parse_openrtb_response( - context: DemandResponse<'_>, - response: PlatformResponse, -) -> Result> { - let provider_id = context.provider_id; - let response_time_ms = context.response_time_ms; - let response = response.response; - let status = response.status(); - if status == StatusCode::NO_CONTENT { - return Ok(AuctionResponse::no_bid(provider_id, response_time_ms)); - } - if !status.is_success() { - if status.is_redirection() { - log::warn!( - "Provider '{provider_id}' returned a redirect; generic OpenRTB redirects are refused" - ); - } - return Ok(AuctionResponse::error(provider_id, response_time_ms) - .with_metadata("error_type", json!("http_status")) - .with_metadata("http_status", json!(status.as_u16()))); - } - - let body = response - .into_body() - .into_bytes_bounded(MAX_RESPONSE_BYTES) - .await - .change_context(TrustedServerError::Auction { - message: format!("Provider {provider_id} response body failed"), - })?; - let value: Value = match serde_json::from_slice(&body) { - Ok(value) => value, - Err(error) => { - log::warn!("Provider '{provider_id}' response JSON was invalid: {error}"); - return Ok(AuctionResponse::error(provider_id, response_time_ms) - .with_metadata("error_type", json!("parse_response"))); - } - }; - - Ok(extract_standard_response( - provider_id, - context.input, - &value, - response_time_ms, - )) -} - -#[cfg(test)] -mod routing_metadata_tests { - use std::collections::BTreeMap; - use std::str::FromStr as _; - - use super::{saturating_bidder_param_counts, unused_bidder_params_count}; - use crate::auction::plan::{AuctionPlan, BidderId, BidderRouteConfig, ProviderId}; - use crate::auction::routing::route_auction; - use crate::auction::test_support::{ - canonical_parity_auction_request, demand_table, plan_config, - }; - - #[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_follows_the_implementation() { - for (implementation, expected) in [("auction.fixture", 0), ("auction.plain-fixture", 1)] { - let endpoint = "https://provider.example/openrtb"; - let provider_id = - ProviderId::from_str("fictional_provider").expect("should parse provider ID"); - let mut config = plan_config(vec![( - "fictional_provider", - demand_table(implementation, endpoint), - )]); - config.bidders = BTreeMap::from([( - BidderId::from_str("exampleBidder").expect("should parse bidder ID"), - BidderRouteConfig { - module: provider_id, - }, - )]); - let plan = AuctionPlan::compile(config).expect("should compile the 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].demand.as_ref(), - &routed.inputs()[0] - ), - expected, - "{implementation} 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 deleted file mode 100644 index 2f2fa20f84..0000000000 --- a/crates/trusted-server-core/src/auction/openrtb/test_executor.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! 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.demand.as_ref(), 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.demand.as_ref(), 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.demand.as_ref(), 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.demand.as_ref(), input)}), - ); - Ok(parsed) -} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index a8e0c9ff38..e61ebf24b6 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -12,10 +12,10 @@ use crate::platform::{PlatformPendingRequest, RuntimeServices}; #[cfg(test)] use super::config::AuctionConfig; -use super::openrtb::unused_bidder_params_count; use super::plan::AuctionPlan; use super::provider::{ - AuctionProvider, GenericOpenRtbProvider, ProviderParseState, ProviderRequestOutcome, + AuctionProvider, ProviderParseState, ProviderRequestOutcome, RoutedDemandProvider, + unused_bidder_params_count, }; #[cfg(test)] use super::routing::RoutedAuction; @@ -308,7 +308,7 @@ pub struct AuctionOrchestrator { enabled: bool, plan_backed: bool, plan: Arc, - planned_providers: Vec>, + planned_providers: Vec>, adserver: Option>, #[cfg(test)] config: AuctionConfig, @@ -320,12 +320,12 @@ pub struct AuctionOrchestrator { #[cfg(test)] pub(crate) struct AuctionOrchestratorHarness { plan: Arc, - providers: Vec>, + providers: Vec>, adserver: Option>, } struct PlannedLaunchState { - provider: Arc, + provider: Arc, started_at: Instant, parse_state: Option, } @@ -345,7 +345,7 @@ impl AuctionOrchestratorHarness { .providers() .iter() .cloned() - .map(GenericOpenRtbProvider::new) + .map(RoutedDemandProvider::new) .map(Arc::new) .collect(); Self { @@ -808,7 +808,7 @@ impl AuctionOrchestrator { .providers() .iter() .cloned() - .map(GenericOpenRtbProvider::new) + .map(RoutedDemandProvider::new) .map(Arc::new) .collect(); Self { @@ -2587,9 +2587,7 @@ mod tests { use crate::auction::config::AuctionConfig; use crate::auction::orchestrator::DispatchAuctionOutcome; use crate::auction::plan::{AuctionPlan, AuctionPlanConfig, NotificationConfig, RoutingMode}; - use crate::auction::provider::{ - AuctionProvider, GenericOpenRtbProvider, ProviderRequestOutcome, - }; + use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome, RoutedDemandProvider}; use crate::auction::routing::{RoutingDiagnostics, route_auction}; use crate::auction::test_support::{create_test_auction_context, demand_table, plan_config}; use crate::auction::types::{ @@ -5585,10 +5583,10 @@ mod tests { 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 - }]}] + "bids": [{ + "seat": "provider-seat", "id": "provider-bid", "slot": "fictional-slot", "price": 2.0, + "creative": "
provider
", "w": 300, "h": 250 + }] })) .expect("should serialize provider response"), ); @@ -5672,10 +5670,10 @@ mod tests { 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 - }]}] + "bids": [{ + "seat": "provider-seat", "id": "provider", "slot": "fictional-slot", "price": 2.0, + "creative": "
provider
", "w": 300, "h": 250 + }] })) .expect("should serialize provider response"), ); @@ -5732,10 +5730,10 @@ mod tests { 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 - }]}] + "bids": [{ + "seat": "provider-seat", "id": "provider", "slot": "fictional-slot", "price": 2.0, + "creative": "
provider
", "w": 300, "h": 250 + }] })) .expect("should serialize provider response"), ); @@ -5797,10 +5795,10 @@ mod tests { 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 - }]}] + "bids": [{ + "seat": "provider-seat", "id": "provider", "slot": "fictional-slot", "price": 2.0, + "creative": "
provider
", "w": 300, "h": 250 + }] })) .expect("should serialize provider response"), ); @@ -5854,16 +5852,16 @@ mod tests { http.push_response( 200, serde_json::to_vec(&serde_json::json!({ - "seatbid":[{"seat":"example-seat","bid":[ + "bids": [ { - "id":format!("bid-{index}"), "impid":format!("inline-{index}"), - "price":2.0, "adm":"
example
", "w":300, "h":250 + "seat": "example-seat", "id":format!("bid-{index}"), "slot":format!("inline-{index}"), + "price":2.0, "creative":"
example
", "w":300, "h":250 }, { - "id":format!("omitted-{index}"), "impid":"fictional-slot", - "price":3.0, "adm":"
omitted
", "w":300, "h":250 + "seat": "example-seat", "id":format!("omitted-{index}"), "slot":"fictional-slot", + "price":3.0, "creative":"
omitted
", "w":300, "h":250 } - ]}] + ] })) .expect("should serialize the upstream response"), ); @@ -5968,9 +5966,9 @@ mod tests { let all_eligible_wire: serde_json::Value = serde_json::from_slice(&bodies[0]) .expect("should parse the all-eligible source's wire request"); assert_eq!( - all_eligible_wire["imp"] + all_eligible_wire["slots"] .as_array() - .expect("should have impressions") + .expect("should have slots") .len(), 2 + inline_providers ); @@ -5995,14 +5993,11 @@ mod tests { let wire: serde_json::Value = serde_json::from_slice(&bodies[index + 1]) .expect("should parse the source's wire request"); assert_eq!( - wire["imp"] - .as_array() - .expect("should have impressions") - .len(), + wire["slots"].as_array().expect("should have slots").len(), 1 ); - assert_eq!(wire["imp"][0]["id"], format!("inline-{index}")); - let fixture = &wire["imp"][0]["ext"]["fixture"]; + assert_eq!(wire["slots"][0]["id"], format!("inline-{index}")); + let fixture = &wire["slots"][0]["ext"]["fixture"]; assert!(fixture.get("stored").is_none()); let bidder = if index == 0 { "alpha" } else { "beta" }; assert_eq!( @@ -6053,7 +6048,7 @@ mod tests { ( &standard_plan, 200, - br#"{"seatbid":[]}"#.to_vec(), + br#"{"bids":[]}"#.to_vec(), BidStatus::NoBid, ), (&fixture_plan, 204, b"{}".to_vec(), BidStatus::NoBid), @@ -6062,7 +6057,7 @@ mod tests { ( &fixture_plan, 200, - br#"{"seatbid":[]}"#.to_vec(), + br#"{"bids":[]}"#.to_vec(), BidStatus::NoBid, ), ]; @@ -6074,7 +6069,7 @@ mod tests { plan, None, ); - let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let provider = RoutedDemandProvider::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() @@ -6112,20 +6107,20 @@ mod tests { 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 - }]}] + "bids": [{ + "seat": "fictional-seat-a", "id": "bid-a", "slot": "fictional-slot", "price": 1.25, + "creative": "
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 - }]}] + "bids": [{ + "seat": "fictional-seat-b", "id": "bid-b", "slot": "fictional-slot", "price": 2.5, + "creative": "
b
", "w": 300, "h": 250 + }] })) .expect("should serialize response b"), ); @@ -6218,12 +6213,14 @@ mod tests { 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"); + let tmax = value["timeout_ms"] + .as_u64() + .expect("should include the logical budget"); 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)); + assert_eq!(value["slots"].as_array().map(Vec::len), Some(1)); } let specs = backend.specs.lock().expect("should lock specs"); assert_eq!(specs.len(), 2); @@ -6238,10 +6235,10 @@ mod tests { 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 - }]}] + "bids": [{ + "seat": "first", "id": "first-bid", "slot": "fictional-slot", "price": 2.0, + "creative": "
first
", "w": 300, "h": 250 + }] })) .expect("should serialize first response"), ); @@ -6399,8 +6396,8 @@ mod tests { &plan, None, ); - let provider_a = GenericOpenRtbProvider::new(plan.providers()[0].clone()); - let provider_b = GenericOpenRtbProvider::new(plan.providers()[1].clone()); + let provider_a = RoutedDemandProvider::new(plan.providers()[0].clone()); + let provider_b = RoutedDemandProvider::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() @@ -6441,8 +6438,8 @@ mod tests { &plan, None, ); - let provider_a = GenericOpenRtbProvider::new(plan.providers()[0].clone()); - let provider_b = GenericOpenRtbProvider::new(plan.providers()[1].clone()); + let provider_a = RoutedDemandProvider::new(plan.providers()[0].clone()); + let provider_b = RoutedDemandProvider::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() @@ -6512,7 +6509,7 @@ mod tests { 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)); + assert_eq!(request_value["slots"].as_array().map(Vec::len), Some(1)); } #[tokio::test] @@ -6607,13 +6604,13 @@ mod tests { 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 + "bids": [{ + "seat": "below_floor", "id": "below", "slot": "fictional-slot", "price": 0.5, + "creative": "
below
", "w": 300, "h": 250 }, { - "id": "below-only", "impid": "below-only-slot", "price": 0.5, - "adm": "
below only
", "w": 300, "h": 250 - }]}] + "seat": "below_floor", "id": "below-only", "slot": "below-only-slot", "price": 0.5, + "creative": "
below only
", "w": 300, "h": 250 + }] })) .expect("should serialize below-floor response"), ); @@ -6622,10 +6619,10 @@ mod tests { 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 - }]}] + "bids": [{ + "seat": "winner", "id": "winner", "slot": "fictional-slot", "price": 2.0, + "creative": "
winner
", "w": 300, "h": 250 + }] })) .expect("should serialize winner response"), ); @@ -6837,7 +6834,9 @@ mod tests { 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"); + let tmax = body["timeout_ms"] + .as_u64() + .expect("should include the budget"); assert!( (100..=175).contains(&tmax), "signer delay should reduce logical budget, got {tmax}" @@ -6915,7 +6914,7 @@ mod tests { let value: serde_json::Value = serde_json::from_slice(&body).expect("should parse signed provider request"); assert!( - value["ext"]["trusted_server"]["signature"].is_string(), + value["signature"]["signature"].is_string(), "should sign every request" ); } diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs index 49fff990f0..8a3f3b72c2 100644 --- a/crates/trusted-server-core/src/auction/plan.rs +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -805,7 +805,7 @@ mod tests { } } - /// A plan with one ordinary `OpenRTB` source called `one`. + /// A plan with one plain source called `one`. fn one_source() -> AuctionPlanConfig { config(vec![("one", table("auction.plain-fixture"))]) } diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index d727629862..35697dc989 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -1,4 +1,5 @@ -//! Trait definition for auction providers. +//! Trait definition for auction providers, and the provider core runs for +//! each demand source the plan selects. use core::any::Any; use std::collections::HashSet; @@ -6,7 +7,7 @@ use std::collections::HashSet; use async_trait::async_trait; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt as _}; -use http::{Method, Request, header}; +use http::{Method, Request}; use serde_json::json; use crate::error::TrustedServerError; @@ -15,14 +16,52 @@ use crate::platform::{ }; use crate::request_signing::{RequestSigner, SigningParams}; -use super::demand::{CompiledDemand, DemandResponse, DemandTransport}; -use super::openrtb::{ - OpenRtbBuildOutcome, RequestFinalization, apply_notification_policy, build_request, - unused_bidder_params_count, -}; -use super::plan::ProviderPlan; +use super::demand::{CompiledDemand, DemandResponse, OutboundRequest, RequestFinalization}; +use super::plan::{NotificationPolicy, ProviderPlan}; use super::routing::{ProviderAuctionInput, RoutedAuction}; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; +use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid}; + +/// Suppress notification URLs using exact returned-seat identity. +pub 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; + } + } +} + +/// Count bidder parameter objects a demand source did not consume. +#[must_use] +pub(crate) fn unused_bidder_params_count( + demand: &dyn CompiledDemand, + input: &ProviderAuctionInput, +) -> u32 { + if demand.consumes_bidder_params() { + return 0; + } + ignored_bidder_params_count(input) +} + +/// Count routed bidder params for a demand source known to ignore them. +/// +/// Public so an implementation that takes no bidder parameters reports the +/// ones it was routed the way core's own provider does. +#[must_use] +pub 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)) + }) +} fn attach_provider_routing_metadata( response: &mut AuctionResponse, @@ -184,25 +223,27 @@ pub trait AuctionProvider: Send + Sync { } } -/// One immutable config-first `OpenRTB` provider instance. +/// The provider core runs for one demand source the plan selects. /// -/// Every instance owns its validated provider identity and carries only its own -/// typed response state across transport. -pub(crate) struct GenericOpenRtbProvider { +/// It sends the request the source builds from its routed input, and hands +/// the answer back to the source to read. Every instance owns its validated +/// provider identity and carries only its own typed response state across +/// transport. +pub(crate) struct RoutedDemandProvider { plan: ProviderPlan, } -/// State created by and returned to one [`GenericOpenRtbProvider`]. +/// State created by and returned to one [`RoutedDemandProvider`]. /// -/// `captured` holds whatever the demand implementation kept from its own -/// request, and only that implementation reads it back. -pub(crate) struct GenericOpenRtbParseState { +/// `captured` holds whatever the demand source kept from its own request, +/// and only that source reads it back. +pub(crate) struct RoutedDemandParseState { provider_id: String, input: ProviderAuctionInput, captured: Option>, } -impl GenericOpenRtbProvider { +impl RoutedDemandProvider { pub(crate) fn new(plan: ProviderPlan) -> Self { Self { plan } } @@ -217,7 +258,7 @@ impl GenericOpenRtbProvider { #[cfg(any(test, feature = "test-utils"))] pub(crate) fn parse_state_for_test(&self, input: ProviderAuctionInput) -> ProviderParseState { - Box::new(GenericOpenRtbParseState { + Box::new(RoutedDemandParseState { provider_id: self.provider_name().to_string(), input, captured: None, @@ -235,7 +276,7 @@ impl GenericOpenRtbProvider { /// request/parse token boundary. #[allow( clippy::too_many_arguments, - reason = "the internal driver keeps routed inputs, both budgets, signer, services, and collision state explicit" + reason = "the provider keeps routed inputs, both budgets, signer, services, and collision state explicit" )] pub(crate) async fn request_bids_routed( &self, @@ -252,23 +293,21 @@ impl GenericOpenRtbProvider { input.common_request().publisher.domain.clone(), "https".to_string(), ); - let request = match build_request( + let demand = self.plan.demand.as_ref(); + let Some(outbound) = demand.build_request( input, - routed, - &self.plan, + routed.transport(), logical_budget_ms, &RequestFinalization { signer, signing_params, }, - )? { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => { - return Ok(ProviderRequestOutcome::Immediate( - AuctionResponse::no_bid(self.provider_name(), 0) - .with_metadata("routing", json!({"skipped_no_usable_demand": true})), - )); - } + )? + else { + return Ok(ProviderRequestOutcome::Immediate( + AuctionResponse::no_bid(self.provider_name(), 0) + .with_metadata("routing", json!({"skipped_no_usable_demand": true})), + )); }; let spec = self @@ -311,37 +350,18 @@ impl GenericOpenRtbProvider { })); } - let sent_impression_ids = request - .imp - .iter() - .filter_map(|impression| impression.id.as_deref()) - .collect::>(); - let body = serde_json::to_vec(&request).change_context(TrustedServerError::Auction { - message: format!( - "Provider {} request serialization failed", - self.provider_name() - ), - })?; - let demand = self.plan.demand.as_ref(); + let OutboundRequest { + body, + headers, + slot_ids, + captured, + } = outbound; let mut builder = Request::builder() .method(Method::POST) - .uri(self.plan.endpoint.as_str()) - .header(header::CONTENT_TYPE, "application/json"); - if demand.field_policy().accept_json { - builder = builder.header(header::ACCEPT, "application/json"); - } - if let Some(headers) = builder.headers_mut() { - demand.prepare_outbound( - headers, - DemandTransport { - headers: routed.transport_headers(), - attested_client_ip: routed.attested_client_ip(), - }, - ); + .uri(self.plan.endpoint.as_str()); + if let Some(target) = builder.headers_mut() { + *target = headers; } - let captured = builder - .headers_ref() - .and_then(|headers| demand.capture_request(&body, headers)); let outbound = builder .body(EdgeBody::from(body)) @@ -368,10 +388,9 @@ impl GenericOpenRtbProvider { } Ok(ProviderRequestOutcome::pending_with_state( pending, - Box::new(GenericOpenRtbParseState { + Box::new(RoutedDemandParseState { provider_id: self.provider_name().to_string(), - input: input - .filtered_slots(|slot| sent_impression_ids.contains(slot.slot().id.as_str())), + input: input.filtered_slots(|slot| slot_ids.contains(slot.slot().id.as_str())), captured, }), )) @@ -379,10 +398,10 @@ impl GenericOpenRtbProvider { /// Parse a response using state created by this exact provider instance. /// - /// The implementation reads its own response and reports an unusable one as - /// an error response. The driver then applies the common notification - /// policy and routing diagnostics, so every demand source reports them the - /// same way. + /// The source reads its own response and reports an unusable one as an + /// error response. The provider then applies the common notification + /// policy and routing diagnostics, so every demand source reports them + /// the same way. pub(crate) async fn parse_response_with_state( &self, response: PlatformResponse, @@ -390,7 +409,7 @@ impl GenericOpenRtbProvider { parse_state: Option<&(dyn Any + Send + Sync)>, ) -> Result> { let parse_state = parse_state - .and_then(|state| state.downcast_ref::()) + .and_then(|state| state.downcast_ref::()) .ok_or_else(|| { Report::new(TrustedServerError::Auction { message: format!( @@ -427,3 +446,307 @@ impl GenericOpenRtbProvider { Ok(parsed) } } + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet, HashSet}; + use std::str::FromStr as _; + use std::sync::Arc; + + use edgezero_core::body::Body as EdgeBody; + use http::Request; + use serde_json::{Value, json}; + + use super::{ + RoutedDemandProvider, apply_notification_policy, saturating_bidder_param_counts, + unused_bidder_params_count, + }; + use crate::auction::plan::{ + AuctionPlan, BidderId, BidderRouteConfig, NotificationPolicy, ProviderId, + }; + use crate::auction::provider::ProviderRequestOutcome; + use crate::auction::routing::route_auction; + use crate::auction::test_support::{ + canonical_parity_auction_request, demand_fixture, demand_table, plan_config, + }; + use crate::auction::types::{AuctionResponse, Bid, BidStatus}; + use crate::platform::PlatformHttpClient; + use crate::platform::test_support::{ + StubBackend, StubHttpClient, build_services_with_backend_and_http_client, + }; + + #[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_follows_the_implementation() { + for (implementation, expected) in [("auction.fixture", 0), ("auction.plain-fixture", 1)] { + let endpoint = "https://provider.example/bid"; + let provider_id = + ProviderId::from_str("fictional_provider").expect("should parse provider ID"); + let mut config = plan_config(vec![( + "fictional_provider", + demand_table(implementation, endpoint), + )]); + config.bidders = BTreeMap::from([( + BidderId::from_str("exampleBidder").expect("should parse bidder ID"), + BidderRouteConfig { + module: provider_id, + }, + )]); + let plan = AuctionPlan::compile(config).expect("should compile the plan"); + let inbound = Request::new(EdgeBody::empty()); + let routed = route_auction(canonical_parity_auction_request(), &inbound, &plan, None); + + assert_eq!( + unused_bidder_params_count( + plan.providers()[0].demand.as_ref(), + &routed.inputs()[0] + ), + expected, + "{implementation} should report only bidder params it ignores" + ); + } + } + + fn bid(seat: Option<&str>) -> Bid { + Bid { + slot_id: "slot".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: Some("
".to_string()), + adomain: None, + bidder: seat.unwrap_or("unknown").to_string(), + returned_seat: seat.map(str::to_string), + width: 300, + height: 250, + nurl: Some("https://n.example".to_string()), + burl: Some("https://b.example".to_string()), + bid_id: None, + ad_id: None, + creative_id: None, + renderer: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: std::collections::HashMap::new(), + } + } + + #[test] + fn notification_suppression_uses_only_the_exact_returned_seat() { + let mut exact = vec![ + bid(Some("exact")), + bid(Some("Exact")), + bid(None), + bid(Some("unknown")), + ]; + apply_notification_policy( + &mut exact, + &NotificationPolicy { + suppress_all: false, + suppress_seats: BTreeSet::from(["exact".to_string()]), + }, + ); + assert!(exact[0].nurl.is_none(), "should suppress the exact seat"); + assert!(exact[1].nurl.is_some(), "matching should be case-sensitive"); + assert!( + exact[2].nurl.is_some(), + "a bid with no returned seat must not match a seat" + ); + assert!( + exact[3].nurl.is_some(), + "the placeholder bidder name is not a returned seat" + ); + + let mut all = vec![bid(Some("exact")), bid(None)]; + 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" + ); + } + + /// The provider sends exactly one request to the endpoint the plan + /// settled, with the method, headers and body the source built, and a + /// source's completion of a bare host is kept. + #[test] + fn a_completed_endpoint_is_sent_one_request_and_a_custom_path_is_kept() { + futures::executor::block_on(async { + for (configured_endpoint, expected_endpoint) in [ + ( + "https://exchange.example", + format!("https://exchange.example{}", demand_fixture::AUCTION_PATH), + ), + ( + "https://exchange.example/bid", + "https://exchange.example/bid".to_string(), + ), + ] { + let mut table = demand_table(demand_fixture::MODULE, configured_endpoint); + table.insert("timeout_ms".to_string(), json!(321)); + let mut config = plan_config(vec![("fictional_provider", table)]); + config.timeout_ms = 321; + config.bidders = BTreeMap::from([( + BidderId::from_str("exampleBidder").expect("should parse bidder ID"), + BidderRouteConfig { + module: ProviderId::from_str("fictional_provider") + .expect("should parse provider ID"), + }, + )]); + let plan = AuctionPlan::compile(config).expect("should compile the 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 = RoutedDemandProvider::new(provider_plan.clone()); + let client = Arc::new(StubHttpClient::new()); + client.push_response_with_headers( + 302, + Vec::new(), + vec![("location", "https://redirect.example.test/bid")], + ); + 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 request"); + let ProviderRequestOutcome::Pending { + request, + parse_state, + } = outcome + else { + panic!("should launch a pending request"); + }; + let selected = services + .http_client() + .select(vec![request]) + .await + .expect("should select one response"); + let response = selected.ready.expect("should receive the response"); + let parsed = provider + .parse_response_with_state(response, 7, parse_state.as_deref()) + .await + .expect("should read the response"); + + assert_eq!(parsed.status, BidStatus::Error); + assert_eq!(parsed.metadata["error_type"], "http_status"); + assert_eq!(parsed.metadata["http_status"], 302); + assert_eq!(parsed.metadata["routing"]["unused_bidder_params_count"], 0); + assert_eq!( + client.recorded_backend_names(), + vec!["stub-backend"], + "a redirect must not trigger a second send" + ); + assert_eq!(client.recorded_request_methods(), vec!["POST"]); + assert_eq!(client.recorded_request_uris(), vec![expected_endpoint]); + let headers = &client.recorded_request_headers()[0]; + assert!( + headers + .iter() + .any(|(name, value)| name == "content-type" && value == "application/json"), + "the source's headers should be sent as built" + ); + let spec = provider_plan.backend_spec(); + assert_eq!(spec.host, "exchange.example"); + assert_eq!(spec.discriminator.as_deref(), Some("fictional_provider")); + let body: Value = serde_json::from_slice(&client.recorded_request_bodies()[0]) + .expect("should parse the recorded body"); + assert_eq!( + body["auction"], "fictional-auction", + "the body should be the one the source built" + ); + } + }); + } + + /// A source that asks for nothing makes no request, and the response + /// says so. + #[test] + fn a_source_with_nothing_to_ask_for_makes_no_request() { + futures::executor::block_on(async { + let mut config = plan_config(vec![( + "fictional_provider", + demand_table(demand_fixture::MODULE, "https://exchange.example/bid"), + )]); + config.bidders = BTreeMap::from([( + BidderId::from_str("exampleBidder").expect("should parse bidder ID"), + BidderRouteConfig { + module: ProviderId::from_str("fictional_provider") + .expect("should parse provider ID"), + }, + )]); + let plan = AuctionPlan::compile(config).expect("should compile the plan"); + let mut request = canonical_parity_auction_request(); + request.slots[0].bidders = std::collections::HashMap::from([( + "trustedServer".to_string(), + json!({"bidderParams":{"exampleBidder":{}}, "storedRequest": false}), + )]); + let inbound = Request::new(EdgeBody::empty()); + let routed = route_auction(request, &inbound, &plan, None); + assert_eq!(routed.inputs().len(), 1, "the slot is routed to the source"); + let provider = RoutedDemandProvider::new(plan.providers()[0].clone()); + let client = Arc::new(StubHttpClient::new()); + let services = build_services_with_backend_and_http_client( + Arc::new(StubBackend), + Arc::clone(&client) as Arc, + ); + + let outcome = provider + .request_bids_routed( + &routed.inputs()[0], + &routed, + 321, + 321, + None, + &services, + &mut HashSet::new(), + ) + .await + .expect("should answer without a request"); + let ProviderRequestOutcome::Immediate(response) = outcome else { + panic!("should answer immediately"); + }; + assert_eq!(response.status, BidStatus::NoBid); + assert_eq!( + response.metadata["routing"]["skipped_no_usable_demand"], + true + ); + assert!( + client.recorded_request_uris().is_empty(), + "nothing should be sent" + ); + let _: AuctionResponse = response; + }); + } +} diff --git a/crates/trusted-server-core/src/auction/routing.rs b/crates/trusted-server-core/src/auction/routing.rs index 594726ffb9..4d13124967 100644 --- a/crates/trusted-server-core/src/auction/routing.rs +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -7,6 +7,7 @@ use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Request, header}; use serde_json::Value; +use super::demand::DemandTransport; use super::plan::{AuctionPlan, BidderId, ProviderId, RoutingMode}; use super::types::{AdSlot, AuctionRequest, MediaType}; @@ -22,7 +23,7 @@ pub(crate) const MAX_ZONE_BYTES: usize = 256; /// Immutable provider-local routing output in deterministic provider-ID order. #[derive(Debug, Clone)] -pub(crate) struct RoutedAuction { +pub struct RoutedAuction { inputs: Vec, skipped_no_eligible_provider_ids: Vec, diagnostics: RoutingDiagnostics, @@ -32,10 +33,22 @@ pub(crate) struct RoutedAuction { } impl RoutedAuction { - pub(crate) fn inputs(&self) -> &[ProviderAuctionInput] { + /// The input each demand source that takes a slot receives. + #[must_use] + pub fn inputs(&self) -> &[ProviderAuctionInput] { &self.inputs } + /// The request facts every source may use beside its input. + #[must_use] + pub fn transport(&self) -> DemandTransport<'_> { + DemandTransport { + headers: &self.transport_headers, + attested_client_ip: self.attested_client_ip, + dnt: self.dnt, + } + } + /// Providers skipped because they had no eligible banner slots. /// /// IDs retain the compiled plan's deterministic provider-ID order. @@ -48,17 +61,21 @@ impl RoutedAuction { } /// Request headers approved for forwarding to a demand source. - pub(crate) fn transport_headers(&self) -> &TransportHeaders { + #[must_use] + pub fn transport_headers(&self) -> &TransportHeaders { &self.transport_headers } /// Platform-attested client IP for transport forwarding. - pub(crate) fn attested_client_ip(&self) -> Option { + #[must_use] + pub 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 { + /// Normalized Do Not Track fact; raw request headers are not exposed to + /// sources. + #[must_use] + pub fn dnt(&self) -> Option { self.dnt } } @@ -267,7 +284,7 @@ pub(crate) struct TrustedProviderRoutes { } impl TrustedProviderRoutes { - #[cfg(test)] + #[cfg(any(test, feature = "test-utils"))] pub(crate) fn new(routes_by_slot: Vec>) -> Self { Self { routes_by_slot } } @@ -618,7 +635,7 @@ mod tests { provider("auction.fixture", RoutingMode::Explicit), ), ( - "openrtb_direct", + "plain_direct", provider("auction.plain-fixture", RoutingMode::Explicit), ), ]); @@ -638,7 +655,7 @@ mod tests { ( BidderId::from_str("beta").expect("should parse bidder"), BidderRouteConfig { - module: ProviderId::from_str("openrtb_direct").expect("should parse provider"), + module: ProviderId::from_str("plain_direct").expect("should parse provider"), }, ), ]); @@ -937,7 +954,7 @@ mod tests { "{name} should not produce stored or inline demand" ); assert_eq!( - input(&routed, "openrtb_direct").slots()[0] + input(&routed, "plain_direct").slots()[0] .bidder_params() .len(), 1, @@ -1117,7 +1134,7 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["open_primary", "stored_a", "stored_b", "openrtb_direct"], + vec!["open_primary", "stored_a", "stored_b", "plain_direct"], "inputs should follow deterministic provider-ID order" ); let open = input(&routed, "open_primary") @@ -1143,7 +1160,7 @@ mod tests { let stored_b = &input(&routed, "stored_b").slots()[0]; assert!(stored_b.bidder_params().is_empty()); assert!(stored_b.allows_stored_fallback_without_candidates()); - let direct = &input(&routed, "openrtb_direct").slots()[0]; + let direct = &input(&routed, "plain_direct").slots()[0]; assert_eq!(direct.bidder_params().len(), 1); assert!(direct.zone().is_none()); for provider in routed.inputs() { @@ -1222,7 +1239,7 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["open_primary", "stored_a", "stored_b", "openrtb_direct"], + vec!["open_primary", "stored_a", "stored_b", "plain_direct"], "no-banner auction should retain every provider's deterministic skip outcome" ); } @@ -1244,7 +1261,7 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["stored_a", "stored_b", "openrtb_direct"], + vec!["stored_a", "stored_b", "plain_direct"], "explicit providers with no routed demand should be retained as skipped" ); } @@ -1253,7 +1270,7 @@ mod tests { fn trusted_routes_admit_explicit_sources_and_ignore_unknown_provider() { let trusted_routes = TrustedProviderRoutes::new(vec![vec![ ProviderId::from_str("open_primary").expect("should parse provider"), - ProviderId::from_str("openrtb_direct").expect("should parse provider"), + ProviderId::from_str("plain_direct").expect("should parse provider"), ProviderId::from_str("unknown_provider").expect("should parse provider"), ]]); let routed = route_auction_with_trusted_routes( @@ -1272,7 +1289,7 @@ mod tests { .iter() .map(|input| input.provider_id().as_str()) .collect::>(), - vec!["open_primary", "openrtb_direct"], + vec!["open_primary", "plain_direct"], "only known server-owned provider routes should admit explicit providers" ); assert_eq!( diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index 3d12cea6f7..702688b1e6 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -209,17 +209,17 @@ pub fn deterministic_signer() -> crate::request_signing::RequestSigner { .expect("should load deterministic signer") } -/// Builds the request the driver would send to the first demand source of -/// `plan` once `request` is routed to it, or `None` when the source keeps no -/// impression. +/// Builds the request the first demand source of `plan` would send once +/// `request` is routed to it, or `None` when the source has nothing to ask +/// for. /// -/// A test in an implementation's own crate checks what its field policy and -/// its extensions do to a request through this. The signature, when `signer` -/// is given, is made over [`golden_signing_params`]. +/// A test in an implementation's own crate checks what its source builds +/// through this. The signature, when `signer` is given, is made over +/// [`golden_signing_params`]. /// /// # Errors /// -/// Returns the error the request builder returns. +/// Returns the error the source's request building returns. pub fn build_for_first_source( plan: &crate::auction::AuctionPlan, request: AuctionRequest, @@ -227,26 +227,63 @@ pub fn build_for_first_source( transport_timeout_ms: u32, signer: Option<&crate::request_signing::RequestSigner>, ) -> Result< - Option, + Option, error_stack::Report, > { - use crate::auction::openrtb::{OpenRtbBuildOutcome, RequestFinalization, build_request}; - let routed = crate::auction::routing::route_auction(request, inbound, plan, None); - let outcome = build_request( + plan.providers()[0].demand.build_request( &routed.inputs()[0], - &routed, - &plan.providers()[0], + routed.transport(), transport_timeout_ms, - &RequestFinalization { + &crate::auction::demand::RequestFinalization { signer, signing_params: golden_signing_params(), }, - )?; - Ok(match outcome { - OpenRtbBuildOutcome::Ready(request) => Some(request), - OpenRtbBuildOutcome::NoImpressions => None, - }) + ) +} + +/// Routes `request` through `plan` as core does for an auction, with the +/// client address the platform attested, if any. +/// +/// A test in a protocol's own crate builds a request from the routed input +/// and transport this returns. +#[must_use] +pub fn route_for_test( + plan: &crate::auction::AuctionPlan, + request: AuctionRequest, + inbound: &Request, + attested_client_ip: Option, +) -> crate::auction::demand::RoutedAuction { + crate::auction::routing::route_auction(request, inbound, plan, attested_client_ip) +} + +/// Routes `request` through `plan` with the server-owned routes a page's +/// slot templates would give, one list of provider names per slot in the +/// request's order. +/// +/// # Panics +/// +/// Panics when a route names a provider id that does not parse. +#[must_use] +pub fn route_with_trusted_routes_for_test( + plan: &crate::auction::AuctionPlan, + request: AuctionRequest, + inbound: &Request, + routes_by_slot: &[&[&str]], +) -> crate::auction::demand::RoutedAuction { + let routes = crate::auction::routing::TrustedProviderRoutes::new( + routes_by_slot + .iter() + .map(|slot| { + slot.iter() + .map(|provider| provider.parse().expect("should parse provider ID")) + .collect() + }) + .collect(), + ); + crate::auction::routing::route_auction_with_trusted_routes( + request, inbound, plan, None, &routes, + ) } /// Routes `request` through `plan` and returns the input its first demand @@ -308,8 +345,7 @@ pub async fn parse_as_first_source( > { let inbound = Request::new(EdgeBody::empty()); let routed = crate::auction::routing::route_auction(request, &inbound, plan, None); - let provider = - crate::auction::provider::GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let provider = crate::auction::provider::RoutedDemandProvider::new(plan.providers()[0].clone()); let state = provider.parse_state_for_test(routed.inputs()[0].clone()); provider .parse_response_with_state(response, response_time_ms, Some(state.as_ref())) @@ -518,16 +554,241 @@ pub(crate) mod adserver_fixture { } } -/// A stand-in demand implementation for core's own tests of what the driver -/// does for an implementation that departs from the plain one. It serves -/// stored requests, forbids all-eligible routing, takes bidder parameters, -/// keeps the unsigned request identity and completes an endpoint that names -/// a host alone. +/// The plain JSON the two stand-in demand implementations speak, so core's +/// own tests see what a source is given and what core does with its answer +/// without naming any protocol. /// -/// It writes each impression's usable bidder parameters under -/// `ext.fixture.bidder`, or the slot id under `ext.fixture.stored` where the -/// slot allows a stored request, and leaves out an impression that has -/// neither. +/// The request carries the auction id, the publisher's domain, the budget, +/// the identifiers, one entry per slot with its id, its banner formats, its +/// floor and the stand-in's slot data, the stand-in's request data, and the +/// signature when the request is signed. The response is read as +/// `{"bids": [...]}`, each bid naming its `slot`, `seat`, `id`, `price`, +/// `creative`, `w`, `h`, `nurl` and `burl`, and admitted by core's rules. +#[cfg(test)] +pub(crate) mod fixture_wire { + use std::collections::{HashMap, HashSet}; + + use error_stack::{Report, ResultExt as _}; + use http::{HeaderMap, HeaderValue, StatusCode, header}; + use serde_json::{Map, Value, json}; + + use crate::auction::admission::{ + BidRejectionReason, ResponseAdmissionDiagnostics, build_bid_dimension_index, + parse_optional_bid_dimension, resolve_bid_dimensions, + }; + use crate::auction::demand::{ + DemandResponse, DemandTransport, OutboundRequest, ProviderAuctionInput, ProviderSlotInput, + RequestFinalization, + }; + use crate::auction::types::{AuctionResponse, Bid}; + use crate::error::TrustedServerError; + use crate::platform::PlatformResponse; + + const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + + /// Builds the stand-in request, with `slot_data` deciding what each slot + /// carries and whether it is sent at all, and `request_data` the + /// stand-in's own request object. + pub(crate) fn build( + input: &ProviderAuctionInput, + transport: DemandTransport<'_>, + timeout_ms: u32, + finalization: &RequestFinalization<'_>, + request_data: Option, + slot_data: impl Fn(&ProviderSlotInput) -> Option, + ) -> Result, Report> { + let common = input.common_request(); + let mut slot_ids = HashSet::new(); + let mut slots = Vec::new(); + for slot in input.slots() { + let Some(data) = slot_data(slot) else { + continue; + }; + let mut entry = Map::from_iter([ + ("id".to_string(), json!(slot.slot().id)), + ( + "formats".to_string(), + json!( + slot.slot() + .formats + .iter() + .map(|format| [format.width, format.height]) + .collect::>() + ), + ), + ("ext".to_string(), data), + ]); + if let Some(floor) = slot.slot().floor_price { + entry.insert("floor".to_string(), json!(floor)); + } + slot_ids.insert(slot.slot().id.clone()); + slots.push(Value::Object(entry)); + } + if slots.is_empty() { + return Ok(None); + } + let mut request = Map::from_iter([ + ("auction".to_string(), json!(common.id)), + ("publisher".to_string(), json!(common.publisher.domain)), + ("timeout_ms".to_string(), json!(timeout_ms)), + ("slots".to_string(), Value::Array(slots)), + ]); + if let Some(dnt) = transport.dnt { + request.insert("dnt".to_string(), json!(dnt)); + } + if common.user.id.is_some() || common.user.eids.is_some() { + request.insert( + "user".to_string(), + json!({"id": common.user.id, "eids": common.user.eids}), + ); + } + if let Some(data) = request_data { + request.insert("ext".to_string(), data); + } + if let Some(signer) = finalization.signer { + let signature = signer.sign_request(&finalization.signing_params)?; + request.insert( + "signature".to_string(), + json!({ + "kid": signer.kid, + "signature": signature, + "ts": finalization.signing_params.timestamp, + "host": finalization.signing_params.request_host, + "scheme": finalization.signing_params.request_scheme, + }), + ); + } + let body = serde_json::to_vec(&request).change_context(TrustedServerError::Auction { + message: "stand-in request serialization failed".to_string(), + })?; + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); + Ok(Some(OutboundRequest { + body, + headers, + slot_ids, + captured: None, + })) + } + + /// Reads the stand-in response. A `204` is a no-bid, any other + /// non-success status or unreadable JSON is an error response, and a + /// body over the size limit is an `Err`. + pub(crate) async fn read( + context: DemandResponse<'_>, + response: PlatformResponse, + ) -> Result> { + let provider_id = context.provider_id; + let response_time_ms = context.response_time_ms; + let response = response.response; + let status = response.status(); + if status == StatusCode::NO_CONTENT { + return Ok(AuctionResponse::no_bid(provider_id, response_time_ms)); + } + if !status.is_success() { + return Ok(AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("http_status")) + .with_metadata("http_status", json!(status.as_u16()))); + } + let body = response + .into_body() + .into_bytes_bounded(MAX_RESPONSE_BYTES) + .await + .change_context(TrustedServerError::Auction { + message: format!("Provider {provider_id} response body failed"), + })?; + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => { + return Ok(AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("parse_response"))); + } + }; + let Some(entries) = value.as_object() else { + return Ok(AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("parse_response"))); + }; + let dimensions_by_slot = build_bid_dimension_index(context.input); + let mut diagnostics = ResponseAdmissionDiagnostics::default(); + let mut bids = Vec::new(); + for entry in entries + .get("bids") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + match read_bid(entry, &dimensions_by_slot) { + 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); + Ok(parsed) + } + + fn read_bid( + entry: &Value, + dimensions_by_slot: &crate::auction::admission::BidDimensionIndex, + ) -> Result { + let text = |key: &str| { + entry + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + let slot_id = text("slot").ok_or(BidRejectionReason::InvalidBid)?; + let width = parse_optional_bid_dimension(entry, "w")?; + let height = parse_optional_bid_dimension(entry, "h")?; + let (width, height) = resolve_bid_dimensions(dimensions_by_slot, &slot_id, width, height)?; + let price = entry + .get("price") + .and_then(Value::as_f64) + .filter(|price| price.is_finite() && *price >= 0.0) + .ok_or(BidRejectionReason::InvalidBid)?; + let creative = text("creative").ok_or(BidRejectionReason::InvalidBid)?; + let seat = text("seat"); + Ok(Bid { + slot_id, + price: Some(price), + currency: "USD".to_string(), + creative: Some(creative), + adomain: None, + bidder: seat.clone().unwrap_or_else(|| "unknown".to_string()), + returned_seat: seat, + width, + height, + nurl: text("nurl"), + burl: text("burl"), + bid_id: text("id"), + ad_id: None, + creative_id: None, + renderer: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + }) + } +} + +/// A stand-in demand implementation for core's own tests of what core does +/// for a source that departs from the plain one. It serves stored requests, +/// forbids all-eligible routing, takes bidder parameters and completes an +/// endpoint that names a host alone. +/// +/// It writes each slot's usable bidder parameters under `ext.fixture.bidder`, +/// or the slot id under `ext.fixture.stored` where the slot allows a stored +/// request, and leaves out a slot that has neither. #[cfg(test)] pub(crate) mod demand_fixture { use core::any::Any; @@ -539,9 +800,10 @@ pub(crate) mod demand_fixture { use serde_json::{Map, Value, json}; use url::Url; + use super::fixture_wire; use crate::auction::demand::{ - CompiledDemand, DemandFieldPolicy, DemandImplementation, DemandResponse, - DemandTimeoutDefault, ProviderAuctionInput, RegsPolicy, RequestExtensions, + CompiledDemand, DemandImplementation, DemandResponse, DemandTimeoutDefault, + DemandTransport, OutboundRequest, ProviderAuctionInput, RequestFinalization, }; use crate::auction::types::AuctionResponse; use crate::error::TrustedServerError; @@ -591,54 +853,37 @@ pub(crate) mod demand_fixture { #[async_trait(?Send)] impl CompiledDemand for FixtureDemand { - fn field_policy(&self) -> DemandFieldPolicy { - DemandFieldPolicy { - imp_tagid: true, - site_ref: true, - precise_geo: true, - additional_consent: true, - unsigned_request_identity: true, - consumes_bidder_params: true, - regs: RegsPolicy::Jurisdiction, - ..DemandFieldPolicy::default() - } - } - - fn augment_request( + fn build_request( &self, - extensions: &mut RequestExtensions<'_>, - _input: &ProviderAuctionInput, - ) -> Result<(), Report> { - for impression in &mut extensions.impressions { - let slot = impression.slot; - let bidder = slot - .bidder_params() - .iter() - .filter(|(_, params)| { - params.as_object().is_some_and(|params| !params.is_empty()) - }) - .map(|(bidder, params)| (bidder.as_str().to_string(), params.clone())) - .collect::>(); - let mut fixture = Map::new(); - if !bidder.is_empty() { - fixture.insert("bidder".to_string(), Value::Object(bidder)); - } else if slot.allows_stored_fallback() { - fixture.insert("stored".to_string(), json!(slot.slot().id)); - } - if fixture.is_empty() { - impression.omitted = true; - continue; - } - *impression.ext = Some(Map::from_iter([( - "fixture".to_string(), - Value::Object(fixture), - )])); - } - *extensions.request = Some(Map::from_iter([( - "fixture".to_string(), - Value::Object(Map::new()), - )])); - Ok(()) + input: &ProviderAuctionInput, + transport: DemandTransport<'_>, + timeout_ms: u32, + finalization: &RequestFinalization<'_>, + ) -> Result, Report> { + fixture_wire::build( + input, + transport, + timeout_ms, + finalization, + Some(json!({"fixture": {}})), + |slot| { + let bidder = slot + .bidder_params() + .iter() + .filter(|(_, params)| { + params.as_object().is_some_and(|params| !params.is_empty()) + }) + .map(|(bidder, params)| (bidder.as_str().to_string(), params.clone())) + .collect::>(); + let mut fixture = Map::new(); + if !bidder.is_empty() { + fixture.insert("bidder".to_string(), Value::Object(bidder)); + } else if slot.allows_stored_fallback() { + fixture.insert("stored".to_string(), json!(slot.slot().id)); + } + (!fixture.is_empty()).then(|| json!({"fixture": fixture})) + }, + ) } async fn parse_response( @@ -646,7 +891,11 @@ pub(crate) mod demand_fixture { context: DemandResponse<'_>, response: PlatformResponse, ) -> Result> { - crate::auction::openrtb::parse_openrtb_response(context, response).await + fixture_wire::read(context, response).await + } + + fn consumes_bidder_params(&self) -> bool { + true } fn as_any(&self) -> &dyn Any { @@ -656,14 +905,13 @@ pub(crate) mod demand_fixture { } /// A stand-in for the plainest demand implementation there can be, which -/// core's own tests of the plan compiler and the request driver name where -/// they need a source. +/// core's own tests of the plan compiler and the provider name where they +/// need a source. /// /// It takes every eligible slot, inherits the auction's timeout, leaves its -/// endpoint as written and has its response read the ordinary way. Its table -/// may set `request_ext` and `imp_ext`, which it writes into the request and -/// into every impression as given, so the driver's tests have an -/// implementation that adds extensions. +/// endpoint as written and ignores bidder parameters. Its table may set +/// `request_ext` and `slot_ext`, which it writes into the request and into +/// every slot as given, so a test has a source that adds data of its own. #[cfg(test)] pub(crate) mod plain_fixture { use core::any::Any; @@ -674,9 +922,10 @@ pub(crate) mod plain_fixture { use serde::Deserialize; use serde_json::{Map, Value}; + use super::fixture_wire; use crate::auction::demand::{ - CONSERVATIVE_LANGUAGE_MAX_BYTES, CompiledDemand, DemandFieldPolicy, DemandImplementation, - DemandResponse, DemandTimeoutDefault, ProviderAuctionInput, RegsPolicy, RequestExtensions, + CompiledDemand, DemandImplementation, DemandResponse, DemandTimeoutDefault, + DemandTransport, OutboundRequest, ProviderAuctionInput, RequestFinalization, accept_endpoint, }; use crate::auction::types::AuctionResponse; @@ -700,7 +949,7 @@ pub(crate) mod plain_fixture { #[derive(Debug, Clone, Default)] pub(crate) struct PlainDemand { request_ext: Map, - imp_ext: Map, + slot_ext: Map, } #[derive(Debug, Deserialize, Default)] @@ -709,7 +958,7 @@ pub(crate) mod plain_fixture { #[serde(default)] request_ext: Map, #[serde(default)] - imp_ext: Map, + slot_ext: Map, } fn compile( @@ -723,31 +972,27 @@ pub(crate) mod plain_fixture { })?; Ok(Arc::new(PlainDemand { request_ext: settings.request_ext, - imp_ext: settings.imp_ext, + slot_ext: settings.slot_ext, })) } #[async_trait(?Send)] impl CompiledDemand for PlainDemand { - fn field_policy(&self) -> DemandFieldPolicy { - DemandFieldPolicy { - language_max_bytes: Some(CONSERVATIVE_LANGUAGE_MAX_BYTES), - regs: RegsPolicy::Jurisdiction, - accept_json: true, - ..DemandFieldPolicy::default() - } - } - - fn augment_request( + fn build_request( &self, - extensions: &mut RequestExtensions<'_>, - _input: &ProviderAuctionInput, - ) -> Result<(), Report> { - *extensions.request = (!self.request_ext.is_empty()).then(|| self.request_ext.clone()); - for impression in &mut extensions.impressions { - *impression.ext = (!self.imp_ext.is_empty()).then(|| self.imp_ext.clone()); - } - Ok(()) + input: &ProviderAuctionInput, + transport: DemandTransport<'_>, + timeout_ms: u32, + finalization: &RequestFinalization<'_>, + ) -> Result, Report> { + fixture_wire::build( + input, + transport, + timeout_ms, + finalization, + (!self.request_ext.is_empty()).then(|| Value::Object(self.request_ext.clone())), + |_| Some(Value::Object(self.slot_ext.clone())), + ) } async fn parse_response( @@ -755,7 +1000,7 @@ pub(crate) mod plain_fixture { context: DemandResponse<'_>, response: PlatformResponse, ) -> Result> { - crate::auction::openrtb::parse_openrtb_response(context, response).await + fixture_wire::read(context, response).await } fn as_any(&self) -> &dyn Any { diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index b2dea2753e..6b6d611523 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -90,19 +90,18 @@ pub struct UserInfo { pub id: Option, /// Decoded consent context for this request. /// - /// Carries both raw consent strings (for `OpenRTB` forwarding) and decoded - /// structured data (for TS-level enforcement and observability). + /// Carries both raw consent strings, for a demand source to forward, and + /// decoded structured data, for enforcement and observability here. /// Skipped during serde since it is populated at runtime from request /// cookies/headers, not from stored data. #[serde(skip)] pub consent: Option, - /// Extended User IDs parsed from the [`crate::constants::COOKIE_EIDS`] cookie. + /// The identifiers the request may carry, from the browser's body, the + /// [`crate::constants::COOKIE_EIDS`] cookie and the identity graph. /// - /// Raw (un-gated) values from the browser; consent gating via - /// [`crate::consent::gate_eids_by_consent`] is applied centrally in the - /// endpoint handlers (the auction and page-bids paths) before any EID - /// reaches a bid request — the provider layer just forwards already-gated - /// EIDs. + /// Gated by the resolved permissions in the endpoint handlers (the auction + /// and page-bids paths) before any reaches a demand source, which forwards + /// what it is given. #[serde(skip)] pub eids: Option>, } @@ -421,17 +420,16 @@ pub struct Bid { pub nurl: Option, /// Billing notification URL pub burl: Option, - /// `OpenRTB` bid identifier — the `id` of the bid object itself. + /// The identifier of the bid itself, as the demand source returned it. /// /// Distinct from [`ad_id`](Self::ad_id): unique per bid instance rather - /// than a creative identifier. Always present per the `OpenRTB` spec, so it - /// is the last-resort `hb_adid` source for bidders that return neither a - /// Prebid Cache UUID nor `adid`. + /// than a creative identifier, so it is the last-resort `hb_adid` source + /// for bidders that return neither a Prebid Cache UUID nor an ad id. #[serde(default, skip_serializing_if = "Option::is_none")] pub bid_id: Option, /// Ad ID from the bidder. pub ad_id: Option, - /// Optional `OpenRTB` creative identifier. + /// The creative identifier, where the demand source returned one. pub creative_id: Option, /// Typed browser renderer capability. #[serde(skip_serializing_if = "Option::is_none")] @@ -491,7 +489,8 @@ impl From<&AuctionResponse> for ProviderSummary { } } -/// `OpenRTB` response metadata for the orchestrator. +/// The orchestrator's summary of the auction, written into the response +/// the auction endpoint returns. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrchestratorExt { pub strategy: String, diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 3fe6719ef7..6c690735ae 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -17,8 +17,8 @@ use crate::error::TrustedServerError; /// Cookie names carrying privacy consent signals. /// /// Used by [`strip_cookies`] to remove consent signals from a `Cookie` header -/// before forwarding requests to partners that receive consent through the -/// `OpenRTB` body instead. +/// before forwarding requests to partners that receive consent in the request +/// body instead. pub const CONSENT_COOKIE_NAMES: &[&str] = &[ COOKIE_EUCONSENT_V2, COOKIE_GPP, diff --git a/crates/trusted-server-core/src/ec/partner.rs b/crates/trusted-server-core/src/ec/partner.rs index d824b3f4b2..3d722fbc4c 100644 --- a/crates/trusted-server-core/src/ec/partner.rs +++ b/crates/trusted-server-core/src/ec/partner.rs @@ -11,7 +11,7 @@ const MAX_SOURCE_DOMAIN_LENGTH: usize = 255; /// Normalizes a partner source domain for use as the canonical EC partner key. /// /// The returned value is lowercase ASCII with any trailing dot removed. It is -/// suitable for matching `OpenRTB` EID `source` values and for use as the EC KV +/// suitable for matching the `source` of an identifier and for use as the EC KV /// `ids` map key. /// /// # Errors diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index c4637b4310..b708125770 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -22,9 +22,11 @@ pub const MIN_API_TOKEN_LENGTH: usize = 32; pub struct PartnerConfig { /// Human-readable partner name. pub name: String, - /// Canonical `OpenRTB` EID source domain and EC KV `ids` key. + /// The source domain the partner's identifiers carry, and the EC KV + /// `ids` key. pub source_domain: String, - /// `OpenRTB` `atype` value. + /// The agent type the partner's identifiers carry, as `openrtb_atype` in + /// the partner's table sets it. pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. pub bidstream_enabled: bool, diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 3a84b26c25..824a55dcb1 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -60,7 +60,6 @@ pub mod middleware; pub mod module_context; pub mod module_name; pub(crate) mod module_secrets; -pub mod openrtb; pub mod permission_signal; pub mod permissions; pub mod platform; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 72a3c0f7f2..bc44e9f84e 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1051,7 +1051,7 @@ pub fn build_services_with_geo(geo: Arc) -> RuntimeServices { /// Build a [`RuntimeServices`] carrying a device module, so a test can drive /// the device seam rather than setting the resolved attributes by hand. /// -/// Setting the attributes directly would prove the `OpenRTB` mapping and +/// Setting the attributes directly would prove a source's mapping of them and /// nothing about whether any module is ever asked, which is the half of the /// chain that breaks. pub fn noop_services_with_device_module( From e43b4528bfa7157082d2fe2bb156081bdbdc7651 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 14 Oct 2026 23:51:04 +0100 Subject: [PATCH 3/7] The guide, the changelog and a design page describe the auction protocol module The design page says what core keeps, what the protocol crate holds, what is not in this change and why, and which tests cover it. The guide's module table and demand paragraph follow the move, and the changelog carries the breaking change for crates outside core. --- CHANGELOG.md | 1 + docs/guide/auction-orchestration.md | 45 ++++++++++-------- docs/guide/integration-guide.md | 32 ++++++------- .../2026-10-14-auction-protocol-design.md | 46 +++++++++++++++++++ 4 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-14-auction-protocol-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6373a277bd..e581522089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking for crates outside core:** core's auction names no protocol. A demand source implements `CompiledDemand`, which builds its own request from the routed input (`build_request`, answering the body, the headers, the slot ids and any state kept for the answer) and reads its own answer, and core sends what it built. OpenRTB 2.6 is the protocol module `crates/auction-protocol/openrtb`, where the request builder, the response reader, the wire types with their extension objects, and `OpenRtbDemand`, the hook trait a demand implementation that speaks OpenRTB fills in, now live, run through core's seam by `OpenRtbDriver`. `auction::openrtb` and core's `openrtb` module are gone, and core's own manifest keeps the generated types only for the response `POST /auction` returns. The dimension admission helpers are `auction::admission`, `apply_notification_policy` and `ignored_bidder_params_count` are `auction::provider`, and `Eid` and `Uid` are `ec::eids`. A deployment's settings do not change. - **Breaking:** Where content comes from is a source module. `[source]` selects the publisher's, and each `[[proxy.asset_routes]]` entry selects its own in a `source` table of the same shape, with `module` naming the source and its settings in the table beneath. Core supplies `origin`, an HTTP origin, and a module a build links can offer another through `IntegrationRegistrationBuilder::with_source_module`. A source answers for each request where that request's content comes from, and core fetches, caches, rewrites the origin host and follows the redirect rule from that answer. `[source]` is required. The origin settings move into the `origin` table, and a document that still writes one where it was is refused at startup naming the new place. An environment variable that set the address is now `TRUSTED_SERVER__SOURCE__ORIGIN__URL`. The old name is refused where the build reads variables, and `ts config push` ignores it, as it ignores any variable for a key the document does not hold. A path in an asset route's `url` names a folder the target path is added under, where a path was refused, a target path with a `.` or `..` segment under a folder is answered `400`, and a route applies `directory_index` as the publisher does. An existing deployment's stored pages and assets keep their keys. **For crates outside core**, `handle_asset_proxy_request` takes the route's source, which `IntegrationRegistry::route_source` gives, and `Publisher::origin_host`, `origin_host_header` and `origin_query_is_stripped` are replaced by `Settings::origin_source` and the `SourceLocation` a source answers. | Previous | Now | diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 83eb258b98..c0ecdad907 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -204,19 +204,20 @@ Convert OrchestrationResult → OpenRTB 2.x Response The orchestrator is composed of several modules: -| Module | Path | Purpose | -| ----------------- | ----------------------------------------- | -------------------------------------------- | -| `orchestrator.rs` | `crates/trusted-server-core/src/auction/` | Parallel execution and bid selection | -| `plan.rs` | `crates/trusted-server-core/src/auction/` | Plan compilation and validation | -| `demand.rs` | `crates/trusted-server-core/src/auction/` | The demand and ad server implementation seam | -| `routing.rs` | `crates/trusted-server-core/src/auction/` | Bidder ownership and demand routing | -| `openrtb.rs` | `crates/trusted-server-core/src/auction/` | Shared OpenRTB request and response handling | -| `provider.rs` | `crates/trusted-server-core/src/auction/` | `AuctionProvider` trait and planned provider | -| `telemetry.rs` | `crates/trusted-server-core/src/auction/` | Auction event construction | -| `types.rs` | `crates/trusted-server-core/src/auction/` | Auction request, response, and bid types | -| `formats.rs` | `crates/trusted-server-core/src/auction/` | TSJS and OpenRTB format conversions | -| `endpoints.rs` | `crates/trusted-server-core/src/auction/` | HTTP handler for `POST /auction` | -| `config.rs` | `crates/trusted-server-core/src/auction/` | Auction configuration types | +| Module | Path | Purpose | +| ---------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `orchestrator.rs` | `crates/trusted-server-core/src/auction/` | Parallel execution and bid selection | +| `plan.rs` | `crates/trusted-server-core/src/auction/` | Plan compilation and validation | +| `demand.rs` | `crates/trusted-server-core/src/auction/` | The demand and ad server implementation seam | +| `routing.rs` | `crates/trusted-server-core/src/auction/` | Bidder ownership and demand routing | +| `admission.rs` | `crates/trusted-server-core/src/auction/` | Admission of a bid against the slots asked for | +| `provider.rs` | `crates/trusted-server-core/src/auction/` | `AuctionProvider` trait and planned provider | +| `telemetry.rs` | `crates/trusted-server-core/src/auction/` | Auction event construction | +| `types.rs` | `crates/trusted-server-core/src/auction/` | Auction request, response, and bid types | +| `formats.rs` | `crates/trusted-server-core/src/auction/` | TSJS and OpenRTB format conversions | +| `endpoints.rs` | `crates/trusted-server-core/src/auction/` | HTTP handler for `POST /auction` | +| `config.rs` | `crates/trusted-server-core/src/auction/` | Auction configuration types | +| `request.rs`, `response.rs`, `demand.rs` | `crates/auction-protocol/openrtb/src/` | The OpenRTB 2.6 request builder, response reader and the hook trait a demand implementation fills in | ### Configuration-first plan @@ -234,9 +235,16 @@ Three demand implementations ship in this repository: behavior; and - `auction.aps` for APS account/SDK fields, response eligibility, and renderer output. -Each `[demand.]` table is one instance of the shared OpenRTB path. -Several tables may name the same implementation or endpoint and stay distinct -through their names, which is what the optional `implementation` line is for. +Each `[demand.]` table is one source running its implementation. Core +routes a source the slots, the publisher, the permission state and the +identifiers, sends the request the source builds and hands the answer back to +the source to read, so core names no protocol. The three implementations above +speak OpenRTB 2.6 through `crates/auction-protocol/openrtb`, which builds every +standard field the same way for all of them and reads an ordinary bid +response, and an implementation decides only its field policy, its own +extensions, its outbound headers and how its answer is read. Several tables +may name the same implementation or endpoint and stay distinct through their +names, which is what the optional `implementation` line is for. The `ad-server.mock` ad server is selected the same way, by `[ad-server] module`, and supplies an ad server implementation rather than a demand one. See [Configuration Rules](/guide/configuration-rules) for the @@ -824,8 +832,9 @@ Until that callback lands, startup remains the mandatory target-aware gate. ### Timeout behavior For each demand source, Trusted Server uses the smaller of its resolved timeout -and the remaining auction budget for launch decisions and OpenRTB `tmax`. The -ad server is not called after the logical auction budget is exhausted. +and the remaining auction budget for launch decisions, and hands that budget to +the source, which an OpenRTB source sends as `tmax`. The ad server is not +called after the logical auction budget is exhausted. No current adapter claims an abortable total-request deadline across demand sources. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 9ca9a49788..97eac2d5a3 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -347,22 +347,22 @@ through the Axum adapter, and the Fastly adapter carries the same round trip. The crate hands out an `IntegrationBuilder`, which names the integration and points at the functions that do the work. -| Part | Purpose | -| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Id | Names the integration in its routes under `/integrations//`, in its browser module and in diagnostics | -| Source | The crate or package name, reported when two builders claim one id, so an operator can tell which crates collided | -| Build function | Reads `Settings` and returns the registration. It is called only when a section selects the module | -| Validate function | The integration's own deploy rules. It runs for every builder, selected or not | -| `.with_module_name(".")` | The name a section selects the module by, which is the crate's path under `crates/` with `.` between the parts. `module_name!()` derives it from the crate's folder | -| `.with_request_preparer(...)` | Optional. Runs before routing, once for a request, whether or not the module is selected | -| `.with_response_finalizer(...)` | Optional. Finishes the response the page path returns, from what the module's request hooks left for the request | -| `.with_auction_token()` | Optional. Declares that the module's browser script reads the token an auction publishes with its winning bids, which is made only when a selected module reads one | -| `.with_secret_settings(...)` | Optional. Lists the settings in the module's own table that hold the name of a secret, so each is looked up as the settings load | -| `.with_plan_registration(...)` | Optional. Registers the module from the compiled auction plan, for a module whose page support follows what the plan selects. It runs whether or not a section selects the module, and its hooks go first | -| `.with_plan_validator(...)` | Optional. Checks the module's settings against the compiled auction plan, when a deployment is validated and as the settings load | -| `.with_demand(...)` and `.with_adserver(...)` | Optional. Registers an auction implementation that `[demand]` or `[ad-server]` can name. A crate that supplies nothing else starts from `IntegrationBuilder::implementations(...)` | -| `.with_loading_cache(...)` | Optional. Offers the loading cache the crate supplies, which a feature selects by the module's name in its `[cache.]` table. An adapter takes the selections with `loading_caches_from` | -| `.with_shared_settings(...)` | Optional. Runs the builder when its vendor's `[shared.]` table is present, for a vendor whose modules all read that one table. A builder the table runs is selected by no section | +| Part | Purpose | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Id | Names the integration in its routes under `/integrations//`, in its browser module and in diagnostics | +| Source | The crate or package name, reported when two builders claim one id, so an operator can tell which crates collided | +| Build function | Reads `Settings` and returns the registration. It is called only when a section selects the module | +| Validate function | The integration's own deploy rules. It runs for every builder, selected or not | +| `.with_module_name(".")` | The name a section selects the module by, which is the crate's path under `crates/` with `.` between the parts. `module_name!()` derives it from the crate's folder | +| `.with_request_preparer(...)` | Optional. Runs before routing, once for a request, whether or not the module is selected | +| `.with_response_finalizer(...)` | Optional. Finishes the response the page path returns, from what the module's request hooks left for the request | +| `.with_auction_token()` | Optional. Declares that the module's browser script reads the token an auction publishes with its winning bids, which is made only when a selected module reads one | +| `.with_secret_settings(...)` | Optional. Lists the settings in the module's own table that hold the name of a secret, so each is looked up as the settings load | +| `.with_plan_registration(...)` | Optional. Registers the module from the compiled auction plan, for a module whose page support follows what the plan selects. It runs whether or not a section selects the module, and its hooks go first | +| `.with_plan_validator(...)` | Optional. Checks the module's settings against the compiled auction plan, when a deployment is validated and as the settings load | +| `.with_demand(...)` and `.with_adserver(...)` | Optional. Registers an auction implementation that `[demand]` or `[ad-server]` can name. A demand implementation builds its own request and reads its own answer, through the crate of the protocol it speaks, such as `crates/auction-protocol/openrtb`. A crate that supplies nothing else starts from `IntegrationBuilder::implementations(...)` | +| `.with_loading_cache(...)` | Optional. Offers the loading cache the crate supplies, which a feature selects by the module's name in its `[cache.]` table. An adapter takes the selections with `loading_caches_from` | +| `.with_shared_settings(...)` | Optional. Runs the builder when its vendor's `[shared.]` table is present, for a vendor whose modules all read that one table. A builder the table runs is selected by no section | ```rust pub fn module_name() -> &'static str { diff --git a/docs/superpowers/specs/2026-10-14-auction-protocol-design.md b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md new file mode 100644 index 0000000000..5d65a1e2ff --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md @@ -0,0 +1,46 @@ +# Design Spec: OpenRTB is an auction protocol module, and core's auction names no protocol + +**Status:** Proposed, 2026-10-14. Implemented on a working branch of the fork, with no pull request raised yet. +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-03-19-auction-orchestration-flow-design.md` +**Related PRs:** none yet +**Issues:** IABTechLab/trusted-server#1336, one of the removals listed in IABTechLab/trusted-server#777 +**Last updated:** 2026-10-14 + +> **Why this spec exists.** Core built every demand source's request as an OpenRTB 2.6 bid request and read every answer as an OpenRTB bid response, so a demand source on another protocol could not be an auction module without translating to OpenRTB first, and a change in what OpenRTB carries was a change in core. + +## 1. Problem + +The demand seam made each demand source a module and left them all speaking the one protocol core knew. Core held the OpenRTB request builder, the response reader, the extension objects, the hook trait a source filled in, which named OpenRTB fields (`imp.tagid`, `site.ref`, `regs`), and the generated OpenRTB types as a dependency of its own. The identifiers a request carries were typed as OpenRTB's `Eid`, though the identity cookie, the `x-ts-eids` header and the identify API carry them with no auction involved. + +A standard is a vendor as much as a product is, and the auction protocol was the largest standard core still carried. + +## 2. Design + +**Core keeps a neutral demand seam.** A compiled demand source implements `CompiledDemand` in `crates/trusted-server-core/src/auction/demand.rs`, which has two jobs. `build_request` takes the routed input (the slots and their formats, the publisher, the permission state the auction resolved and the identifiers the request may carry), the transport facts (the admitted headers, the attested client address and the Do Not Track fact) and the signing input core settled, and answers an `OutboundRequest`, being the body, the headers, the ids of the slots the request asks for and any state the source keeps for reading the answer, or `None` when the source has nothing to ask for. `parse_response` reads the answer into core's bids. Core sends what the source built, with `POST`, to the endpoint the plan settled, and reads the answer against the slots the request asked for. + +**Admission stays in core.** Whatever protocol a source reads, a bid is admitted when it names a slot the request carried and its dimensions match exactly one format that slot asked for. Those rules, the dimension index, the rejection reasons and the bounded diagnostics are `crates/trusted-server-core/src/auction/admission.rs`, so a reader in any crate admits a bid by the rules core applies. The notification policy and the count of bidder parameters a source ignored are the provider's, in `provider.rs`. + +**The identifiers are the identity module's.** `Eid` and `Uid` are `crates/trusted-server-core/src/ec/eids.rs`, beside the helpers that resolve them from the identity graph and carry them in the response header. The identity cookie, that header, the identify API and the auction request body carry this shape, and a protocol formats it into its own request. + +**OpenRTB is a protocol module.** `crates/auction-protocol/openrtb` holds the standard. `wire.rs` re-exports the generated types and adds the extension objects (`user.ext`, `regs.ext`, `ext.trusted_server`). `request.rs` builds the bid request from the routed facts, every standard field chosen through the source's field policy, and signs it. `response.rs` reads an ordinary bid response. `demand.rs` holds what a source that speaks OpenRTB decides, the `OpenRtbDemand` trait with the field policy, the extensions, the outbound headers and the response reader, and `OpenRtbDriver`, which runs one such source through core's seam. A demand implementation that speaks OpenRTB depends on this crate, implements `OpenRtbDemand`, and its `compile` wraps the source in `OpenRtbDriver::new`. Core's `as_any` answers with the wrapped source, so an implementation reads its own settings back as before. The plain implementation, `auction-protocol.openrtb`, lives in the same crate, as it did. + +**The demand crates depend on the protocol crate.** `auction.prebid-server` and `auction.aps` implement `OpenRtbDemand` and take the wire types from the protocol crate. `ad-server.mock` reads bids by core's admission rules and names no protocol crate. + +**Core no longer holds the driver.** `auction/openrtb.rs`, its tests, and core's `openrtb.rs` are gone, and `auction/demand.rs` carries none of the OpenRTB field policy. Core's own test stand-ins speak a plain JSON of their own, so core's tests show what a source is given and what core does with its answer without naming a protocol. + +**A deployment's settings do not change.** Every `[demand.]` table, every implementation name and every setting in a table reads as before. + +## 3. What is not in this change + +- The response the auction endpoint returns to the caller of `POST /auction`. `auction/formats.rs` still writes it as an OpenRTB bid response through the generated types, with the orchestrator's summary in `ext.orchestrator` and a typed renderer in `ext.trusted_server`, and the endpoint still calls that conversion, so core still depends on `trusted-server-openrtb` for it. Moving it needs a protocol trait the endpoint calls and a rule for which protocol writes the response when the selected sources' protocols differ, or when none is selected and the auction is disabled, which today still answers an empty OpenRTB response. That rule is a decision for the maintainers before the move. +- The settings keys that name the standard, `openrtb_atype` in a partner's table and `openrtb_only` as a consent forwarding mode, which a deployment's document writes and which are unchanged. +- The Prebid EID cookie parser and the consent decoders, which move in series of their own. + +## 4. Tests + +- Core's provider sends exactly one request with the method, headers and body the source built, keeps a source's completion of a bare host, and makes no request for a source with nothing to ask for. +- Core's admission helpers admit an exact match, infer a unique match, refuse an ambiguous or mismatched one and a slot the request did not carry, read a dimension as a positive whole number or absent, and count rejections by reason. +- The protocol crate's driver holds the consent matrix, the language limits and the device attributes under the plain implementation and under a stand-in that departs from it, omits an impression a source leaves without demand and the whole request when none is left, refuses a source that claims `ext.trusted_server`, signs after the source's extensions with every owned key, formats the identifiers as the user's `eids`, and hands core the headers the field policy asks for and the slot ids the request carries. +- The protocol crate's reader isolates a malformed sibling, accepts omitted and `USD` currency and refuses another, admits bids by core's rules, keeps the exact returned seat for the notification policy, and classifies `204`, a non-success status, a redirect and unreadable JSON. +- Every demand crate's own tests pass unchanged in what they assert, reading the request the driver builds through the protocol crate's test helper. From 1e50a3236422cacdb33ead13663977cbb8b146cb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 00:32:18 +0100 Subject: [PATCH 4/7] The OpenRTB crate's identifier formatter and tests satisfy clippy The formatter's inner struct names every field, and two header reads pass the method itself. --- crates/auction-protocol/openrtb/src/tests.rs | 7 +++++-- crates/auction-protocol/openrtb/src/wire.rs | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/auction-protocol/openrtb/src/tests.rs b/crates/auction-protocol/openrtb/src/tests.rs index 02bff83698..a1086d31f0 100644 --- a/crates/auction-protocol/openrtb/src/tests.rs +++ b/crates/auction-protocol/openrtb/src/tests.rs @@ -962,11 +962,14 @@ fn the_driver_builds_what_core_sends_and_classifies_every_answer() { outbound .headers .get(header::CONTENT_TYPE) - .map(|v| v.as_bytes()), + .map(http::HeaderValue::as_bytes), Some(b"application/json".as_slice()) ); assert_eq!( - outbound.headers.get(header::ACCEPT).map(|v| v.as_bytes()), + outbound + .headers + .get(header::ACCEPT) + .map(http::HeaderValue::as_bytes), Some(b"application/json".as_slice()), "the plain implementation asks for JSON" ); diff --git a/crates/auction-protocol/openrtb/src/wire.rs b/crates/auction-protocol/openrtb/src/wire.rs index 491a6d3c89..2aebb1c27d 100644 --- a/crates/auction-protocol/openrtb/src/wire.rs +++ b/crates/auction-protocol/openrtb/src/wire.rs @@ -46,7 +46,6 @@ pub fn format_eids(eids: &[trusted_server_core::ec::eids::Eid]) -> Vec { serde_json::Value::Object(object) => Some(object.clone()), _ => None, }), - ..Default::default() }) .collect(), ..Default::default() From 6a44163056eded3d8ebf6969e5a90c64b940841f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 17:38:43 +0100 Subject: [PATCH 5/7] The auction protocol design states who applies admission and what the series breaks Review round 1 found that the design named an ad server module that does not exist, said the Prebid EID cookie parser and the consent decoders were still to move when the two series below had moved them, and said a reader in any crate admits a bid by the rules core applies when core only offers the rules. The design now says what the code does on all three, adds the adapters, consent and privacy, signing, failure, compatibility and the missing tests, asks the reviewer whether core should enforce admission, and records the review round. The auction README and the Prebid guide now name the provider and the files that exist. --- .../trusted-server-core/src/auction/README.md | 8 +- docs/guide/integrations/prebid.md | 2 +- .../2026-10-14-auction-protocol-design.md | 79 +++++++++++++++---- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index d4114c8deb..fa8271577b 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -17,7 +17,7 @@ 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] + D --> E[provider::RoutedDemandProvider sends the requests sources build] E --> F[orchestrator::AuctionOrchestrator dispatches demand sources] F --> G[Demand responses are normalized] G --> H{Ad server configured?} @@ -61,7 +61,7 @@ startup and operator validation. It reads `[demand]`, `[ad-server]` and Adapters then call `AuctionPlan::validate_for_target` for backend naming, fan-out support, and target resource limits. -A plan-backed orchestrator contains generic OpenRTB demand sources compiled from +A plan-backed orchestrator contains the demand sources compiled from the plan. `AuctionOrchestrator::register_provider` and the old concrete Prebid provider remain test-only parity code. They are not extension APIs. @@ -84,7 +84,7 @@ Each `[auction.bidders.]` route has one owner, named by its ## Demand execution -`provider::GenericOpenRtbProvider` owns the shared transport path for the +`provider::RoutedDemandProvider` owns the shared transport path for the `auction-protocol.openrtb`, `auction.prebid-server` and `auction.aps` implementations. An implementation receives routed and privacy-approved facts, not the raw inbound request. @@ -169,7 +169,7 @@ as `iabtl`. Endpoints must be HTTPS, or HTTP to `127.0.0.1`, `::1` or - `plan.rs` owns plan validation and target capability checks. - `demand.rs` owns the demand and ad server implementation seam. - `routing.rs` assigns slots and bidder params to demand sources. -- `openrtb.rs` builds shared requests and parses standard responses. +- `admission.rs` holds the rules a response reader admits a bid by. - `provider.rs` runs plan-backed demand requests and per-implementation parsing. - `orchestrator.rs` owns fan-out, deadlines, the ad server call, and local ranking. diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 7bdd742df3..d287d2017c 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -1050,7 +1050,7 @@ The browser integration lives in while provider execution uses [crates/trusted-server-core/src/auction/provider.rs](https://github.com/IABTechLab/trusted-server/blob/main/crates/trusted-server-core/src/auction/provider.rs) and shared request construction uses -[crates/trusted-server-core/src/auction/openrtb.rs](https://github.com/IABTechLab/trusted-server/blob/main/crates/trusted-server-core/src/auction/openrtb.rs). +[crates/auction-protocol/openrtb/src/request.rs](https://github.com/IABTechLab/trusted-server/blob/main/crates/auction-protocol/openrtb/src/request.rs). `PrebidAuctionProvider` remains test-only legacy parity code. ### OpenRTB request construction diff --git a/docs/superpowers/specs/2026-10-14-auction-protocol-design.md b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md index 5d65a1e2ff..9cfb29850e 100644 --- a/docs/superpowers/specs/2026-10-14-auction-protocol-design.md +++ b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md @@ -1,46 +1,93 @@ -# Design Spec: OpenRTB is an auction protocol module, and core's auction names no protocol +# Design Spec: OpenRTB is an auction protocol module, and core sends the request a demand source builds **Status:** Proposed, 2026-10-14. Implemented on a working branch of the fork, with no pull request raised yet. **Author:** 51Degrees (contributed), for Tech Lab review -**Related specs:** `2026-03-19-auction-orchestration-flow-design.md` +**Related specs:** `2026-03-19-auction-orchestration-flow-design.md`, `2026-10-14-consent-decoders-design.md`, `2026-10-14-identifier-modules-design.md` and `2026-10-14-no-modules-crate-design.md`, all in IABTechLab/trusted-server#1084, which holds every design document of the stack **Related PRs:** none yet **Issues:** IABTechLab/trusted-server#1336, one of the removals listed in IABTechLab/trusted-server#777 -**Last updated:** 2026-10-14 +**Last updated:** 2026-10-15 > **Why this spec exists.** Core built every demand source's request as an OpenRTB 2.6 bid request and read every answer as an OpenRTB bid response, so a demand source on another protocol could not be an auction module without translating to OpenRTB first, and a change in what OpenRTB carries was a change in core. ## 1. Problem -The demand seam made each demand source a module and left them all speaking the one protocol core knew. Core held the OpenRTB request builder, the response reader, the extension objects, the hook trait a source filled in, which named OpenRTB fields (`imp.tagid`, `site.ref`, `regs`), and the generated OpenRTB types as a dependency of its own. The identifiers a request carries were typed as OpenRTB's `Eid`, though the identity cookie, the `x-ts-eids` header and the identify API carry them with no auction involved. +The demand seam made each demand source a module and left them all speaking the one protocol core knew. Core held the OpenRTB request builder, the response reader, the extension objects, the hook trait a source filled in, which named OpenRTB fields (`imp.tagid`, `site.ref`, `regs`), and the generated OpenRTB types as a dependency of its own. The identifiers a request carries were typed as OpenRTB's `Eid`, though the Prebid EID cookie, the identify API, the `x-ts-eids` response header and the body of the browser's `POST /auction` request carry the same shape and none of them is a bid request. A standard is a vendor as much as a product is, and the auction protocol was the largest standard core still carried. ## 2. Design -**Core keeps a neutral demand seam.** A compiled demand source implements `CompiledDemand` in `crates/trusted-server-core/src/auction/demand.rs`, which has two jobs. `build_request` takes the routed input (the slots and their formats, the publisher, the permission state the auction resolved and the identifiers the request may carry), the transport facts (the admitted headers, the attested client address and the Do Not Track fact) and the signing input core settled, and answers an `OutboundRequest`, being the body, the headers, the ids of the slots the request asks for and any state the source keeps for reading the answer, or `None` when the source has nothing to ask for. `parse_response` reads the answer into core's bids. Core sends what the source built, with `POST`, to the endpoint the plan settled, and reads the answer against the slots the request asked for. +**What core asks of a demand source.** A compiled demand source implements `CompiledDemand` in `crates/trusted-server-core/src/auction/demand.rs`. `build_request` takes the routed input (the slots and their formats, the publisher, the consent the permission signal modules vouched for and the identifiers the request may carry), the transport facts (the admitted headers, the attested client address and the Do Not Track fact), the time the source has left in milliseconds and the signing input core settled. The time is `timeout_ms`, being the lesser of the time left in the auction and the source's own timeout, and the OpenRTB driver sends it as `tmax`. `build_request` answers an `OutboundRequest`, being the body, the headers, the ids of the slots the request asks for and any state the source keeps for reading the answer, or `None` when the source has nothing to ask for. `parse_response`, the source's reader, reads the answer into core's bids. `consumes_bidder_params` says whether the bidder parameters routed to a source are request data for that source. Where the answer is `false`, which is the default, core reports the number of those parameters as ignored. `as_any` lets the implementation that created the source read its own settings back. -**Admission stays in core.** Whatever protocol a source reads, a bid is admitted when it names a slot the request carried and its dimensions match exactly one format that slot asked for. Those rules, the dimension index, the rejection reasons and the bounded diagnostics are `crates/trusted-server-core/src/auction/admission.rs`, so a reader in any crate admits a bid by the rules core applies. The notification policy and the count of bidder parameters a source ignored are the provider's, in `provider.rs`. +Core sends what the source built to the endpoint the plan settled, hands the reader the routed input narrowed to the slots the request asked for, and once the reader returns, core applies the notification policy and reports the ignored-parameter count (`crates/trusted-server-core/src/auction/provider.rs`). `OutboundRequest` carries no method and no URL, so every request is a `POST` to that endpoint. An implementation can complete a bare host with a path when the plan is compiled (`canonicalize_endpoint`), and a source cannot change the path or the query for each request. -**The identifiers are the identity module's.** `Eid` and `Uid` are `crates/trusted-server-core/src/ec/eids.rs`, beside the helpers that resolve them from the identity graph and carry them in the response header. The identity cookie, that header, the identify API and the auction request body carry this shape, and a protocol formats it into its own request. +**Admission is core's rules, and each reader applies them.** A bid is admitted when it names a slot the request carried and its dimensions match exactly one format that slot asked for. `crates/trusted-server-core/src/auction/admission.rs` holds those rules, the dimension index, the rejection reasons and the bounded diagnostics as public helpers (`build_bid_dimension_index`, `parse_optional_bid_dimension` and `resolve_bid_dimensions` among them), and core does not call them outside its tests. After `parse_response` returns, the provider applies the notification policy and the routing metadata and nothing else, and the orchestrator ranks the bids of a successful response by their slot id without checking that the slot was one the request carried. Each reader therefore has to call the helpers. The OpenRTB reader (`crates/auction-protocol/openrtb/src/response.rs`) and Prebid Server's reader call them, and APS checks each bid against the slots with code of its own. A source on another protocol that does not call them can return a bid for a slot the request never carried, and that bid takes part in ranking. -**OpenRTB is a protocol module.** `crates/auction-protocol/openrtb` holds the standard. `wire.rs` re-exports the generated types and adds the extension objects (`user.ext`, `regs.ext`, `ext.trusted_server`). `request.rs` builds the bid request from the routed facts, every standard field chosen through the source's field policy, and signs it. `response.rs` reads an ordinary bid response. `demand.rs` holds what a source that speaks OpenRTB decides, the `OpenRtbDemand` trait with the field policy, the extensions, the outbound headers and the response reader, and `OpenRtbDriver`, which runs one such source through core's seam. A demand implementation that speaks OpenRTB depends on this crate, implements `OpenRtbDemand`, and its `compile` wraps the source in `OpenRtbDriver::new`. Core's `as_any` answers with the wrapped source, so an implementation reads its own settings back as before. The plain implementation, `auction-protocol.openrtb`, lives in the same crate, as it did. +**Open question for the reviewer.** Should core enforce admission itself, by checking the bids `parse_response` returns against the slots the request asked for before they are ranked, so that no source can return a bid for a slot the request did not carry? This revision states what the code does and decides nothing. -**The demand crates depend on the protocol crate.** `auction.prebid-server` and `auction.aps` implement `OpenRtbDemand` and take the wire types from the protocol crate. `ad-server.mock` reads bids by core's admission rules and names no protocol crate. +**The identifiers are core's own type.** `Eid` and `Uid` are in `crates/trusted-server-core/src/ec/eids.rs`, beside the helpers that resolve them from the identity graph and carry them in the response header. The Prebid EID cookie (in the shape the script writes today), the `x-ts-eids` response header, the identify API and the body of the browser's `POST /auction` request carry `Eid` entries, `IdentifierModule::identifiers` returns them, and a protocol formats them into its own request. The Prebid module's cookie reader (`crates/auction/prebid/src/eids.rs`) builds `Eid` values directly, with no protocol crate between. -**Core no longer holds the driver.** `auction/openrtb.rs`, its tests, and core's `openrtb.rs` are gone, and `auction/demand.rs` carries none of the OpenRTB field policy. Core's own test stand-ins speak a plain JSON of their own, so core's tests show what a source is given and what core does with its answer without naming a protocol. +**How this series differs from two related specs.** `2026-10-14-identifier-modules-design.md` lists "the OpenRTB `Eid` type the interface returns, which stays where core's auction keeps it" as not in its change. This series reverses that statement. `Eid` and `Uid` are core's own types, `IdentifierModule::identifiers` returns them, and the OpenRTB crate turns them into OpenRTB's own `Eid` objects with `format_eids` in `wire.rs`. `2026-10-14-consent-decoders-design.md` says "the OpenRTB crate asks the crates for the standards once it is out". This series does not ask the crates. The Prebid EID cookie parser and the consent decoders already moved in `split/41` and `split/42`, which sit under this series, and the OpenRTB crate has no dependency on any permission signal crate. Its request builder (`request.rs`) reads the TCF, GPP and US Privacy strings through the consent context's neutral interface, by the scheme names `tcf`, `gpp` and `us_privacy`, which the three shipped modules record their strings under, and reads the GPP section ids from the `gpp` record, as core's builder did before it moved. A module that records a string under another scheme name is not read. Asking the crates instead (`tcf::tc_string`, `gpp::gpp_string`, `gpp::section_ids` and `us_privacy::us_privacy_string`) would give the OpenRTB crate a dependency on each of those crates. -**A deployment's settings do not change.** Every `[demand.]` table, every implementation name and every setting in a table reads as before. +**OpenRTB is a protocol module.** `crates/auction-protocol/openrtb` holds the standard. `wire.rs` re-exports the generated types and adds the extension objects (`user.ext`, `regs.ext`, `ext.trusted_server`) and `format_eids`. `request.rs` builds the bid request from the routed facts, every standard field chosen through the source's field policy, and signs it. `response.rs` reads an ordinary bid response. `demand.rs` holds what a source that speaks OpenRTB decides, the `OpenRtbDemand` trait with the field policy, the extensions, the outbound headers and the response reader, and `OpenRtbDriver`, which runs one such source through core's seam. A demand implementation that speaks OpenRTB depends on this crate, implements `OpenRtbDemand`, and its `compile` wraps the source in `OpenRtbDriver::new`. The driver answers `CompiledDemand::as_any` with the wrapped source, so an implementation reads its own settings back as before. The plain implementation, `auction-protocol.openrtb`, lives in the same crate, as it did, and its compiled source is the struct `PlainDemand`, where the struct was called `OpenRtbDemand` before that name went to the trait. + +**The demand crates depend on the protocol crate.** `auction.prebid-server`, `auction.aps` and the test fixture `testing.iabtl` (`crates/testing/iabtl`) implement `OpenRtbDemand`, and the first two take the wire types from the protocol crate. `ad-server.iabtl` (`crates/ad-server/iabtl`) is an ad server and not a demand source, reads bids by core's admission helpers and names no protocol crate. + +**Core no longer holds the driver.** `crates/trusted-server-core/src/auction/openrtb.rs` is gone from core and its tests are now `tests.rs` in the protocol crate. `crates/trusted-server-core/src/openrtb.rs` is gone and its wire types are now `wire.rs` there. `auction/demand.rs` carries none of the OpenRTB field policy. Core's own test stand-ins (`fixture_wire` in `crates/trusted-server-core/src/auction/test_support.rs`) speak a plain JSON of their own, so core's tests show what a source is given and what core does with its answer without naming a protocol. + +**Consent and privacy.** What a source may be given is still settled in core, before any source is asked. `POST /auction` (`handle_auction` in `crates/trusted-server-core/src/auction/endpoints.rs`) and the publisher path both skip the auction where the resolved permissions do not set basic ad selection, and both pass the identifiers through `gate_eids_by_permissions` before routing. The consent context holds only the signals a configured module vouched for (`ConsentContext::keep_only`). The OpenRTB driver can leave data out of what core approved and cannot add what core removed, and a source narrows the consent that travels in the request body through `OpenRtbDemand::body_consent`, which Prebid Server uses for its consent forwarding mode. Core does not read the body a source builds, so a source on another protocol decides for itself which of its input and transport facts it puts in the request, and the transport facts include the admitted `Cookie` header. + +**Signing.** `RequestFinalization` hands every source the signer the auction admitted, or none for an unsigned request, with what the signature is made over. The struct and its fields were private to core before. A source can use the signer to sign any bytes (`RequestSigner::sign`), and in production only the OpenRTB driver uses the signer, to sign the fixed payload and write `ext.trusted_server` (`request.rs`). The refusal of a source that claims `ext.trusted_server` is the driver's check on its own requests, so the rule covers OpenRTB requests only. + +**When a source fails.** A source that cannot build its request fails alone. The orchestrator logs the error from `build_request`, records a `launch_failed` error response for that source and carries on with the others (`crates/trusted-server-core/src/auction/orchestrator.rs`). A source that answers `None` makes no request and is recorded as a no-bid that carries `skipped_no_usable_demand` in its routing metadata. An error from `parse_response` is recorded as an error response of type `parse_response` for that source alone. The OpenRTB reader returns such an error for a body over 1 MiB (`MAX_RESPONSE_BYTES` in `response.rs`). It answers a no-bid for `204`, an error response for any other non-success status and for unreadable JSON, and a no-bid carrying `unsupported_currency` for an answer in a currency other than `USD`. + +**Compatibility.** A deployment's settings do not change. Every `[demand.]` table, every implementation name and every setting in a table reads as before. The request body an OpenRTB source sends is unchanged on the wire, and the signed and unsigned goldens of the plain implementation, of Prebid Server and of APS are byte for byte what they were before this series. One case can differ. The generated `Uid` holds its `ext` as a JSON object, so `format_eids` no longer sends an identifier whose `ext` is not an object, where the old request sent any JSON value. The body parser (`parse_client_auction_uid`) and the Prebid cookie parser already drop such a value, so only an identifier module that a deployment adds, and that returns one, would reach that case. + +The Rust interface changes for a crate outside core, `CHANGELOG.md` marks the change as breaking, and nothing keeps the old paths. + +| Before this series | In this series | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `DemandFieldPolicy`, `RegsPolicy`, `RequestExtensions`, `ImpressionExtension` and `CONSERVATIVE_LANGUAGE_MAX_BYTES` in `trusted_server_core::auction::demand` | The same names in `trusted_server_auction_protocol_openrtb::demand` | +| The OpenRTB types, `to_openrtb_i32` and the request extension objects in `trusted_server_core::openrtb` | `trusted_server_auction_protocol_openrtb::wire` | +| `BidExt`, `BidTrustedServerExt` and `ResponseExt` in `trusted_server_core::openrtb` | Private to `auction/formats.rs` | +| `Eid` and `Uid` in `trusted_server_core::openrtb` | `trusted_server_core::ec::eids` | +| `parse_openrtb_response` in `trusted_server_core::auction::openrtb` | `trusted_server_auction_protocol_openrtb::response` | +| The dimension admission helpers in `trusted_server_core::auction::openrtb` | `trusted_server_core::auction::admission` | +| `apply_notification_policy` and `ignored_bidder_params_count` in `trusted_server_core::auction::openrtb` | `trusted_server_core::auction::provider` | +| The hooks of `CompiledDemand` (`field_policy`, `augment_request`, `prepare_outbound` and the rest) | The same hooks on `OpenRtbDemand`, with `CompiledDemand` now `build_request`, `parse_response`, `consumes_bidder_params` and `as_any` | +| The struct `OpenRtbDemand` of the plain implementation | `PlainDemand`, with `OpenRtbDemand` now the trait | + +The protocol crate has a `test-utils` Cargo feature, which turns on core's feature of the same name and exposes `test_support::build_for_first_source` to other crates' tests. The Prebid Server and APS crates turn that feature on in their dev-dependencies. + +**Adapters and tools.** No adapter and no tool changes, so no adapter test changes. The Axum, Cloudflare, Fastly and Spin adapters (the `app.rs` of each adapter crate) and the `ts` tool (`crates/trusted-server-cli/src/app_config.rs`) each offer the plain implementation by calling `trusted_server_auction_protocol_openrtb::builder()`, which is unchanged. ## 3. What is not in this change -- The response the auction endpoint returns to the caller of `POST /auction`. `auction/formats.rs` still writes it as an OpenRTB bid response through the generated types, with the orchestrator's summary in `ext.orchestrator` and a typed renderer in `ext.trusted_server`, and the endpoint still calls that conversion, so core still depends on `trusted-server-openrtb` for it. Moving it needs a protocol trait the endpoint calls and a rule for which protocol writes the response when the selected sources' protocols differ, or when none is selected and the auction is disabled, which today still answers an empty OpenRTB response. That rule is a decision for the maintainers before the move. +- The response the auction endpoint returns to the caller of `POST /auction`. `auction/formats.rs` still writes it as an OpenRTB bid response through the generated types, with the orchestrator's summary in `ext.orchestrator` and a typed renderer in `ext.trusted_server`, and the endpoint still calls that conversion, so core still depends on `trusted-server-openrtb` for it. Where the auction is disabled and where the consent gate denies it, the endpoint answers an empty OpenRTB response without calling a source (`auction/endpoints.rs`). Moving the response needs a protocol trait the endpoint calls and a rule for which protocol writes it in three cases, being when the selected sources' protocols differ, when none is selected and the auction is disabled, and when the consent gate denies the auction. That rule is a decision for the maintainers before the move. - The settings keys that name the standard, `openrtb_atype` in a partner's table and `openrtb_only` as a consent forwarding mode, which a deployment's document writes and which are unchanged. -- The Prebid EID cookie parser and the consent decoders, which move in series of their own. +- The OpenRTB device type table. `DeviceAttributes::device_type` in `crates/trusted-server-core/src/ec/device.rs` holds OpenRTB's device type numbers and `request.rs` sends that value as `devicetype`, so a device module resolves in OpenRTB's numbering and a source on another protocol is given those numbers. +- Asking the permission signal crates for the strings, which `2026-10-14-consent-decoders-design.md` expected of the OpenRTB crate. The OpenRTB crate reads the strings by scheme name, as set out under "How this series differs from two related specs". +- Core enforcing admission itself, which is the open question under "Admission is core's rules, and each reader applies them". ## 4. Tests - Core's provider sends exactly one request with the method, headers and body the source built, keeps a source's completion of a bare host, and makes no request for a source with nothing to ask for. - Core's admission helpers admit an exact match, infer a unique match, refuse an ambiguous or mismatched one and a slot the request did not carry, read a dimension as a positive whole number or absent, and count rejections by reason. -- The protocol crate's driver holds the consent matrix, the language limits and the device attributes under the plain implementation and under a stand-in that departs from it, omits an impression a source leaves without demand and the whole request when none is left, refuses a source that claims `ext.trusted_server`, signs after the source's extensions with every owned key, formats the identifiers as the user's `eids`, and hands core the headers the field policy asks for and the slot ids the request carries. -- The protocol crate's reader isolates a malformed sibling, accepts omitted and `USD` currency and refuses another, admits bids by core's rules, keeps the exact returned seat for the notification policy, and classifies `204`, a non-success status, a redirect and unreadable JSON. -- Every demand crate's own tests pass unchanged in what they assert, reading the request the driver builds through the protocol crate's test helper. +- The protocol crate's driver holds the consent matrix, the language limits and the device attributes under the plain implementation and under a stand-in that departs from it, omits an impression a source leaves without demand and the whole request when none is left, refuses a source that claims `ext.trusted_server`, signs after the source's extensions with every owned key, formats the identifiers as the user's `eids`, hands core the headers the field policy asks for and the slot ids the request carries, and answers `as_any` with the source it runs and `consumes_bidder_params` from that source's field policy (`the_driver_answers_as_any_with_the_source_it_runs`). +- The protocol crate's reader isolates a malformed sibling, accepts omitted and `USD` currency, answers a no-bid carrying `unsupported_currency` for another and an error for a currency that is not a string, admits bids by core's rules, keeps the exact returned seat for the notification policy, and classifies `204`, a redirect (`302`) and unreadable JSON. +- The plain implementation, Prebid Server and APS each compare the request the driver builds with an exact full golden, signed and unsigned (`signed_and_unsigned_requests_have_exact_full_goldens` in `crates/auction-protocol/openrtb/src/lib.rs`, `pbs_driver_exact_golden_preserves_profile_policy` and `the_signed_request_has_an_exact_full_golden` in `crates/auction/prebid-server/src/lib.rs`, and `the_driver_request_matches_the_exact_golden` and `the_signed_driver_request_matches_the_exact_golden` in `crates/auction/aps/src/lib.rs`). The goldens are the ones that existed before this series. +- `format_eids` carries the source, the identifier, its agent type and an `ext` that is an object (`identifiers_are_formatted_as_eids_with_their_agent_type` in `wire.rs`). +- Every demand crate's own tests assert what they asserted before, reading the request the driver builds through the protocol crate's test helper. + +Some behavior has no test. A source on another protocol being a module is shown only by core's `#[cfg(test)]` stand-ins, because the test crate `crates/testing/iabtl` wraps the OpenRTB driver and the only direct implementation of `CompiledDemand` outside core is the driver. No test shows a source that skips the admission helpers being stopped, because nothing in core stops it. No test sends the OpenRTB reader a non-success status other than the `302`, and none sends it a body over 1 MiB, although core's tests send one to a stand-in reader. No test runs an identifier whose `ext` is not an object through `format_eids`. + +## Review log + +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/45-openrtb` at `82414cad4`. Issues found, three of them blocking. The spec named `ad-server.mock`, which does not exist, said the Prebid EID cookie parser and the consent decoders move in series of their own when they had already moved in the two series below this one, and said a reader in any crate admits a bid by the rules core applies when core only offers the rules and each reader has to call them. All three are fixed in this revision, with the partly true claims, the coverage gaps and the advice the round gave, and whether core should enforce admission is left as an open question. + +## Revision record + +| Date | Change | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-14 | First draft. | +| 2026-10-15 | Fixes from review round 1. The module name `ad-server.iabtl`, the title, where `Eid` and the consent names sit and how that differs from two related specs, admission as helpers each reader calls with an open question, new text on consent and privacy, signing, failure, compatibility, adapters and the missing tests, and the review log. | From d3e05fc2988fc624201475ef69cbbdb55c0edded Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 18:01:02 +0100 Subject: [PATCH 6/7] The auction protocol design takes its round 2 corrections Review round 2 found no blocking issues and three corrections. The related specs line pointed at a pull request that was closed on 15 October, so it now names the open pull request that holds each of the three specs and the one that holds the earlier design documents of the stack. The note on an identifier extension that is not an object now says the extension is dropped and the identifier is still sent. The round 1 entry in the review log no longer says all of that round's advice was applied, because two stale code comments are left for a later change. The round 2 entry and a revision record row are added. --- .../specs/2026-10-14-auction-protocol-design.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-10-14-auction-protocol-design.md b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md index 9cfb29850e..338e1fca2d 100644 --- a/docs/superpowers/specs/2026-10-14-auction-protocol-design.md +++ b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md @@ -2,7 +2,7 @@ **Status:** Proposed, 2026-10-14. Implemented on a working branch of the fork, with no pull request raised yet. **Author:** 51Degrees (contributed), for Tech Lab review -**Related specs:** `2026-03-19-auction-orchestration-flow-design.md`, `2026-10-14-consent-decoders-design.md`, `2026-10-14-identifier-modules-design.md` and `2026-10-14-no-modules-crate-design.md`, all in IABTechLab/trusted-server#1084, which holds every design document of the stack +**Related specs:** `2026-03-19-auction-orchestration-flow-design.md` (on the main branch), `2026-10-14-consent-decoders-design.md` (IABTechLab/trusted-server#1383), `2026-10-14-identifier-modules-design.md` (IABTechLab/trusted-server#1382) and `2026-10-14-no-modules-crate-design.md` (IABTechLab/trusted-server#1384), with the earlier design documents of the stack in IABTechLab/trusted-server#1342 **Related PRs:** none yet **Issues:** IABTechLab/trusted-server#1336, one of the removals listed in IABTechLab/trusted-server#777 **Last updated:** 2026-10-15 @@ -41,7 +41,7 @@ Core sends what the source built to the endpoint the plan settled, hands the rea **When a source fails.** A source that cannot build its request fails alone. The orchestrator logs the error from `build_request`, records a `launch_failed` error response for that source and carries on with the others (`crates/trusted-server-core/src/auction/orchestrator.rs`). A source that answers `None` makes no request and is recorded as a no-bid that carries `skipped_no_usable_demand` in its routing metadata. An error from `parse_response` is recorded as an error response of type `parse_response` for that source alone. The OpenRTB reader returns such an error for a body over 1 MiB (`MAX_RESPONSE_BYTES` in `response.rs`). It answers a no-bid for `204`, an error response for any other non-success status and for unreadable JSON, and a no-bid carrying `unsupported_currency` for an answer in a currency other than `USD`. -**Compatibility.** A deployment's settings do not change. Every `[demand.]` table, every implementation name and every setting in a table reads as before. The request body an OpenRTB source sends is unchanged on the wire, and the signed and unsigned goldens of the plain implementation, of Prebid Server and of APS are byte for byte what they were before this series. One case can differ. The generated `Uid` holds its `ext` as a JSON object, so `format_eids` no longer sends an identifier whose `ext` is not an object, where the old request sent any JSON value. The body parser (`parse_client_auction_uid`) and the Prebid cookie parser already drop such a value, so only an identifier module that a deployment adds, and that returns one, would reach that case. +**Compatibility.** A deployment's settings do not change. Every `[demand.]` table, every implementation name and every setting in a table reads as before. The request body an OpenRTB source sends is unchanged on the wire, and the signed and unsigned goldens of the plain implementation, of Prebid Server and of APS are byte for byte what they were before this series. One case can differ. The generated `Uid` holds its `ext` as a JSON object, so `format_eids` drops an `ext` that is not an object and still sends the identifier, where the old request sent any JSON value as the `ext`. The body parser (`parse_client_auction_uid`) and the Prebid cookie parser (`structured_cookie_uid_to_openrtb`) already drop such an `ext` and keep the identifier, so only an identifier module that a deployment adds, and that returns such an `ext`, would reach that case. The Rust interface changes for a crate outside core, `CHANGELOG.md` marks the change as breaking, and nothing keeps the old paths. @@ -83,7 +83,8 @@ Some behavior has no test. A source on another protocol being a module is shown ## Review log -1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/45-openrtb` at `82414cad4`. Issues found, three of them blocking. The spec named `ad-server.mock`, which does not exist, said the Prebid EID cookie parser and the consent decoders move in series of their own when they had already moved in the two series below this one, and said a reader in any crate admits a bid by the rules core applies when core only offers the rules and each reader has to call them. All three are fixed in this revision, with the partly true claims, the coverage gaps and the advice the round gave, and whether core should enforce admission is left as an open question. +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/45-openrtb` at `82414cad4`. Issues found, three of them blocking. The spec named `ad-server.mock`, which does not exist, said the Prebid EID cookie parser and the consent decoders move in series of their own when they had already moved in the two series below this one, and said a reader in any crate admits a bid by the rules core applies when core only offers the rules and each reader has to call them. All three are fixed in this revision, with the partly true claims, the coverage gaps and the advice the round gave, except that the two stale code comments at `auction/plan.rs:31` and `prebid-server/src/lib.rs:7397` are left for a later change, and whether core should enforce admission is left as an open question. +2. **Round 2, 2026-10-15.** A lighter review in a fresh session, against this page at `6a4416305`, of the round 1 fixes and the new text. No blocking issues. The related specs line no longer points at the closed specs pull request (IABTechLab/trusted-server#1084), the note on an identifier `ext` that is not an object says the `ext` is dropped and the identifier is still sent, and the round 1 entry says the two stale code comments are left for a later change. ## Revision record @@ -91,3 +92,4 @@ Some behavior has no test. A source on another protocol being a module is shown | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-10-14 | First draft. | | 2026-10-15 | Fixes from review round 1. The module name `ad-server.iabtl`, the title, where `Eid` and the consent names sit and how that differs from two related specs, admission as helpers each reader calls with an open question, new text on consent and privacy, signing, failure, compatibility, adapters and the missing tests, and the review log. | +| 2026-10-15 | Corrections from review round 2. The related specs line, the note on an identifier `ext` that is not an object, the round 1 entry's claim about two code comments, and the round 2 entry in the review log. | From 3177fd5e1a56f7fbda01e46c8434efb80bb23fa4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 21:12:32 +0100 Subject: [PATCH 7/7] The design header names the pull request that carries it --- .../specs/2026-10-14-auction-protocol-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-10-14-auction-protocol-design.md b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md index 338e1fca2d..b7eef81254 100644 --- a/docs/superpowers/specs/2026-10-14-auction-protocol-design.md +++ b/docs/superpowers/specs/2026-10-14-auction-protocol-design.md @@ -1,11 +1,11 @@ # Design Spec: OpenRTB is an auction protocol module, and core sends the request a demand source builds -**Status:** Proposed, 2026-10-14. Implemented on a working branch of the fork, with no pull request raised yet. +**Status:** Proposed, 2026-10-14. Raised as draft pull request IABTechLab/trusted-server#1388 (from jwrosewell/trusted-server#40), for Tech Lab review. **Author:** 51Degrees (contributed), for Tech Lab review -**Related specs:** `2026-03-19-auction-orchestration-flow-design.md` (on the main branch), `2026-10-14-consent-decoders-design.md` (IABTechLab/trusted-server#1383), `2026-10-14-identifier-modules-design.md` (IABTechLab/trusted-server#1382) and `2026-10-14-no-modules-crate-design.md` (IABTechLab/trusted-server#1384), with the earlier design documents of the stack in IABTechLab/trusted-server#1342 -**Related PRs:** none yet +**Related specs:** `2026-03-19-auction-orchestration-flow-design.md` (on the main branch), `2026-10-14-consent-decoders-design.md` (IABTechLab/trusted-server#1383), `2026-10-14-identifier-modules-design.md` (IABTechLab/trusted-server#1382) and `2026-10-14-no-modules-crate-design.md` (IABTechLab/trusted-server#1384), with every design document of the stack in IABTechLab/trusted-server#1342 +**Related PRs:** IABTechLab/trusted-server#1388, jwrosewell/trusted-server#40 **Issues:** IABTechLab/trusted-server#1336, one of the removals listed in IABTechLab/trusted-server#777 -**Last updated:** 2026-10-15 +**Last updated:** 2026-10-16 > **Why this spec exists.** Core built every demand source's request as an OpenRTB 2.6 bid request and read every answer as an OpenRTB bid response, so a demand source on another protocol could not be an auction module without translating to OpenRTB first, and a change in what OpenRTB carries was a change in core.