diff --git a/.cargo/config.toml b/.cargo/config.toml index f8fcfadaf..b9fb4b5ca 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- build-axum = "build -p trusted-server-adapter-axum" diff --git a/.env.example b/.env.example index a7f5973cd..b7fc2ea3f 100644 --- a/.env.example +++ b/.env.example @@ -40,41 +40,33 @@ TRUSTED_SERVER__REQUEST_SIGNING__ENABLED=false # ============================================================================= # Integrations # ============================================================================= +# Which integrations run is set in the TOML file, as +# `[integration] provider = ["prebid", "nextjs"]`. There is no per-integration +# enabled flag any more. An overlay below changes a setting the file already +# carries, and it cannot add one the file leaves out. -# Prebid -TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.example.com/openrtb2/auction -# TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 -# TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus -# TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' -# Compatibility env shape for bidder -> zone -> params overrides -# TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_abc"}}}' -# Preferred canonical env shape for future generic rules -# TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_abc"}}]' -# TRUSTED_SERVER__INTEGRATIONS__PREBID__SUPPRESS_NURL_BIDDERS=exampleBidder,anotherBidder -# TRUSTED_SERVER__INTEGRATIONS__PREBID__AUTO_CONFIGURE=false -# TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false -# TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false +# Prebid, the browser side. The Prebid Server endpoint is a demand source now, +# so it is set under Demand sources below rather than here. +# TRUSTED_SERVER__INTEGRATION__PREBID__ACCOUNT_ID= +# TRUSTED_SERVER__INTEGRATION__PREBID__TIMEOUT_MS=1000 +# TRUSTED_SERVER__INTEGRATION__PREBID__CLIENT_SIDE_BIDDERS__0=kargo +# TRUSTED_SERVER__INTEGRATION__PREBID__DEBUG=false # Next.js -TRUSTED_SERVER__INTEGRATIONS__NEXTJS__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__NEXTJS__REWRITE_ATTRIBUTES=href,link,url +# TRUSTED_SERVER__INTEGRATION__NEXTJS__REWRITE_ATTRIBUTES=href,link,url # Didomi -TRUSTED_SERVER__INTEGRATIONS__DIDOMI__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__DIDOMI__SDK_ORIGIN=https://sdk.privacy-center.org -# TRUSTED_SERVER__INTEGRATIONS__DIDOMI__API_ORIGIN=https://api.privacy-center.org +# TRUSTED_SERVER__INTEGRATION__DIDOMI__SDK_ORIGIN=https://sdk.privacy-center.org +# TRUSTED_SERVER__INTEGRATION__DIDOMI__API_ORIGIN=https://api.privacy-center.org # Permutive -TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__ORGANIZATION_ID= -# TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__WORKSPACE_ID= -# TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__PROJECT_ID= +# TRUSTED_SERVER__INTEGRATION__PERMUTIVE__ORGANIZATION_ID= +# TRUSTED_SERVER__INTEGRATION__PERMUTIVE__WORKSPACE_ID= +# TRUSTED_SERVER__INTEGRATION__PERMUTIVE__PROJECT_ID= # Lockr -TRUSTED_SERVER__INTEGRATIONS__LOCKR__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__LOCKR__APP_ID= -# TRUSTED_SERVER__INTEGRATIONS__LOCKR__CACHE_TTL_SECONDS=3600 +# TRUSTED_SERVER__INTEGRATION__LOCKR__APP_ID= +# TRUSTED_SERVER__INTEGRATION__LOCKR__CACHE_TTL_SECONDS=3600 # ============================================================================= # Rewrite Configuration (optional) diff --git a/.github/workflows/inspector.yml b/.github/workflows/inspector.yml new file mode 100644 index 000000000..5d58b3d93 --- /dev/null +++ b/.github/workflows/inspector.yml @@ -0,0 +1,26 @@ +name: "Permissions Inspector" + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build-inspector-wasm: + name: build inspector wasm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep -oP 'channel = "\K[^"]+' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: wasm32-unknown-unknown + - name: Build the inspector engine + run: ./scripts/build-inspector-wasm.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f1bbe27a..e37232630 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,8 +66,12 @@ jobs: run: BID_DELAY=3 ./scripts/template-cache-local-test.sh inline test-axum: - name: cargo test (axum native) - runs-on: ubuntu-latest + name: cargo test (axum native, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -104,12 +108,33 @@ jobs: run: | cargo test --package trusted-server-openrtb-codegen --target "$(rustc -vV | sed -n 's/host: //p')" + # The core library's unit tests otherwise run only on the WebAssembly + # targets, which build with panic=abort, so their harness stops at the + # first failing test and reports every later one as never run. A run that + # looks like a single failure can hide many more. This native run reports + # them all at once, which is what makes a red build readable. + - name: Run host-target core library tests + run: | + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/host: //p')" + + # The seam-probe fixture crate's own unit tests only run when invoked on + # the host target, because no adapter alias compiles its test target. Run + # them here so a fault in the seam fixture cannot sit unseen behind a + # green gate set. + - name: Run host-target seam-probe integration tests + run: | + cargo test --package trusted-server-integration-seam-probe --target "$(rustc -vV | sed -n 's/host: //p')" + - name: Verify Fastly WASM release build run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 test-cloudflare: - name: cargo check (cloudflare native + wasm32-unknown-unknown) - runs-on: ubuntu-latest + name: cargo check (cloudflare native + wasm32-unknown-unknown, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 24b9e06aa..a4a0af36c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ /spin /spin.sig +# logs +*.log + # EdgeZero local KV store (created by edgezero-adapter-axum framework) .edgezero/ /dist/prebid/ diff --git a/AGENTS.md b/AGENTS.md index b6e61ecf9..dd1db47e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,11 @@ crates/ trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component) trusted-server-cli/ # Host-target `ts` operator CLI + device/ + fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider) + edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core) + geo/ # vendor geo provider crates (host geo is injected by the adapter) + permission-signal/ # permission signal provider crates, one per scheme (gpc, gpp, tcf, us-privacy), and core links none trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -59,7 +64,9 @@ fastly compute serve # Deploy to Fastly fastly compute publish -# Run Axum dev server (native — no Viceroy) +# Run Axum dev server (native — no Viceroy). Settings load at runtime from the +# platform config store on every adapter; publish an operator config with +# `ts config push` (see trusted-server.example.toml for the template). cargo run -p trusted-server-adapter-axum # Test Axum adapter only @@ -151,6 +158,24 @@ cd crates/trusted-server-js/lib && node build-all.mjs cargo install viceroy --version 0.17.0 --locked --force ``` +### Windows (use WSL for the Linux-only tests) + +The Rust adapter tests run natively on Windows through the cargo aliases +(`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`), +and CI runs them on `ubuntu-latest`. + +The Docker-based integration suite (`scripts/integration-tests.sh`) and the +Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, +which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them +inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the +WSL distro with the same toolchain as `.tool-versions` (rustup + the +`wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then +run the scripts from a clone on the WSL native filesystem for fast builds. +The CLI crate (`trusted-server-cli`) does not build on a Windows host either, +because its dependency `edgezero-adapter-fastly` uses a standard library +feature that is unstable on Windows, so run its tests and the template cache +harness (`scripts/template-cache-local-test.sh`), which builds it, in WSL too. + --- ## Coding Conventions @@ -278,12 +303,48 @@ impl core::error::Error for MyError {} ## Other guidelines +- Use US English spelling everywhere: code, identifiers, comments, + documentation, tests, commit messages, and configuration. For example, write + `color`, `behavior`, and `optimize`, not `colour`, `behaviour`, or `optimise`. + Where a term comes from an external source (for example the IAB TCF purpose + names), match that source's spelling even when it is not US English. - Use only example or fictional information in comments, tests, docs, examples, and similar non-runtime materials. (eg. for urls use: example.com domains only) - Do not write or commit real domains, customer names, credentials, configuration values, or other potentially sensitive real-world information in comments, tests, docs, or examples. +### Permission model terminology + +Permissions are the primitive. A provider declares the permissions it requires +(`required_permissions`) and the system decides whether each is _set_. Consent +is only one of many ways a permission may be established. Country or +jurisdiction rules (a `Granted` group baseline), legitimate interest, or +configuration can set a permission with no consent at all. + +- A provider that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated provider **runs once its required permissions are set**, by whatever + method. + +**Evidence is not rationed, use is.** Every provider and every integration sees +all the evidence available for a request, including host signals such as the TLS +JA4 and HTTP/2 signals. The core never decides which vendor may see what, +because withholding a signal from one vendor and not another discriminates +between them, and the core stays neutral. What a vendor may *do* with the +evidence is governed by the permissions it declares and the system sets. Access +is universal, use is gated. + +The practical consequence: never "fix" a vendor's access to a signal by hiding +the signal. If a use needs controlling, express it as a permission. A change +that removes evidence from a provider's reach is working against the +architecture, not protecting it. + +- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`, + GDPR and TCF strings) where it genuinely means a consent signal. In the + permission layer prefer "permission", "set" / "unset", and "signal" (consent + is one kind of signal, alongside privacy and opt-out signals). + --- ## Git Commit Conventions @@ -300,6 +361,54 @@ Bad: `"fix: added feature flags"` --- +## Provider Architecture + +Each vendor-differentiated capability is pluggable behind its own trait, so a +deployment selects an implementation and the core stays neutral. Every +pluggable thing follows one configuration convention, which is a top-level +table named for the job, a `provider` key that selects what runs, and a +`[.]` table for a provider that has settings. The rules and what +is checked when are in `docs/guide/configuration-rules.md`, which is the page +to read before changing any provider configuration. + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ---------------------------------------- | ------------------------- | -------------------------------------------- | ---------------------------- | +| Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | `hmac`, `host_signals`, `client_fixed` (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | `builtin`, User-Agent only (the default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | None, no location (the default), or `platform` | `crates/geo/` | +| Permission signals | `PermissionSignalProvider` (`permission_signal/mod.rs`) | `[permission_signal] provider` (an ordered list) | `gpc`, `gpp_sale_opt_out`, `us_privacy`, `tcf`, all of them with no list | `crates/permission-signal/` | +| Auction demand | `DemandImplementation` (`auction/demand.rs`) | `[demand] provider` (a list) | `openrtb`, `prebid_server`, `aps` | an integration builder | +| Ad server | `AdServerImplementation` (`auction/demand.rs`) | `[adserver] provider` | `adserver_mock` | an integration builder | +| Page integrations | `IntegrationBuilder` (`integrations/mod.rs`) | `[integration] provider` (a list) | `datadome`, `didomi`, `google_tag_manager`, `gpt`, `gpt_diagnostics`, `js_asset_proxy`, `lockr`, `nextjs`, `osano`, `permutive`, `prebid`, `sourcepoint`, `testlight` | an adapter-supplied builder | + +`openrtb`, `prebid_server`, `aps` and `adserver_mock` supply implementations +only. They are not page integrations and cannot be named in +`[integration] provider`. + +Principles for adding or changing a provider: + +- **Core stays neutral.** The trait and the host-neutral default live in + `trusted-server-core`. Host-specific and vendor implementations live in their + own crates and are injected by the adapter (for example `build_device_provider` + and `build_geo_provider`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Providers read request evidence, not a fixed parameter set.** A provider must + be able to see everything about the request it needs (User-Agent, headers, and + host signals such as the TLS JA4 and HTTP/2 signals) through an evidence + abstraction rather than a hard-coded struct of fields. Host signals come from + the host (the Fastly SDK) and are opt-in, so a neutral provider triggers no + host signal calls. +- **Providers are separated by capability but composed per request, and one may + need another's output.** Geo resolves the country and region the permission + model uses, and the permission model gates whether the Edge Cookie provider + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + multiple vendor providers share a backend (for example a vendor's Edge Cookie, + geo, and device provider on one cloud pipeline) they share a single call per + request rather than calling independently. Give a provider the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -313,7 +422,8 @@ IntegrationRegistration::builder(ID) ``` - Integration IDs match JS directory names: `prebid` (deferred), `lockr`, `permutive`, `datadome`, `didomi`, `testlight`. -- `creative` is JS-only (no Rust registration); `nextjs`, `aps`, `adserver_mock` are Rust-only. +- An integration runs when `[integration] provider` names it. There is no `enabled` flag. +- `creative` is JS-only (no Rust registration), and `nextjs` is Rust-only. `openrtb`, `prebid_server`, `aps` and `adserver_mock` register demand or ad server implementations rather than page integrations. - Integrations opt into deferred loading via `.with_deferred_js()` on the registration builder. Deferred modules are served as separate `".to_string()); renderer.bid_id = Some("upstream-renderer-bid".to_string()); renderer.creative_id = None; - renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "upstream-renderer-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); + renderer.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "upstream-renderer-bid".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("the APS renderer payload should be a JSON object"), + ); let result = OrchestrationResult { provider_responses: vec![], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([ (missing.slot_id.clone(), missing), (whitespace.slot_id.clone(), whitespace), @@ -1618,17 +1609,23 @@ mod tests { let settings = make_settings(); let auction_request = make_auction_request(); let mut bid = make_bid("div-gpt-top", "aps", Some(2.75)); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "fictional-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); + bid.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "fictional-bid".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("the APS renderer payload should be a JSON object"), + ); let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) @@ -1658,17 +1655,23 @@ mod tests { bid.bid_id = Some("fictional-bid".to_string()); bid.ad_id = Some("fictional-ad".to_string()); bid.creative_id = Some("fictional-creative".to_string()); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "fictional-bid".to_string(), - creative_id: Some("fictional-creative".to_string()), - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); + bid.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "fictional-bid".to_string(), + creative_id: Some("fictional-creative".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("the APS renderer payload should be a JSON object"), + ); let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) @@ -1724,7 +1727,7 @@ mod tests { let auction_request = make_auction_request(); let result = OrchestrationResult { provider_responses: vec![], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::new(), total_time_ms: 50, metadata: HashMap::new(), @@ -1761,7 +1764,7 @@ mod tests { response_time_ms: 42, metadata: HashMap::new(), }], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([ (top_bid.slot_id.clone(), top_bid), (sidebar_bid.slot_id.clone(), sidebar_bid), @@ -1848,9 +1851,12 @@ mod tests { } #[test] - fn convert_to_openrtb_response_uses_parallel_mediation_when_mediator_configured() { + fn convert_to_openrtb_response_uses_parallel_adserver_when_adserver_configured() { let mut settings = make_settings(); - settings.auction.mediator = Some("adserver_mock".to_string()); + settings.adserver = crate::provider_table::ProviderChoice::new( + Some("adserver_mock".to_string()), + std::collections::BTreeMap::new(), + ); let auction_request = make_auction_request(); let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); @@ -1860,8 +1866,8 @@ mod tests { assert_eq!( json["ext"]["orchestrator"]["strategy"], - json!("parallel_mediation"), - "should use mediation strategy when mediator is configured" + json!("parallel_adserver"), + "should use ad server decision strategy when adserver is configured" ); } diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 432303928..9b2d3332c 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -1,26 +1,25 @@ -//! Auction orchestration module for managing multi-provider bidding. +//! Auction orchestration across the demand sources and the ad server a +//! deployment selects. //! -//! This module provides an extensible framework for running auctions across -//! multiple providers (Prebid, Amazon APS, Google GAM, etc.) with support for -//! parallel execution and mediation strategies. -//! -//! Note: Individual auction providers are located in the `integrations` module -//! (e.g., `crate::integrations::aps`, `crate::integrations::prebid`). +//! `[demand] provider` selects the demand sources and `[adserver] provider` +//! the optional ad server. Their implementations are registered by +//! integrations through [`demand`], so this module names no vendor. use error_stack::Report; use crate::error::TrustedServerError; +use crate::integrations::IntegrationBuilder; use crate::settings::Settings; use std::sync::Arc; pub mod config; pub mod context; +pub mod demand; pub mod endpoints; pub mod formats; pub(crate) mod openrtb; pub mod orchestrator; pub mod plan; -pub(crate) mod profile; pub mod provider; pub(crate) mod routing; pub mod telemetry; @@ -32,7 +31,7 @@ pub use config::AuctionConfig; pub use context::{ContextQueryParams, ContextValue, build_url_with_context_params}; pub use orchestrator::AuctionOrchestrator; pub use plan::{ - AuctionPlan, BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, + AdServerPlan, AuctionPlan, BidderId, BidderRouteConfig, NotificationConfig, ProviderId, RoutingMode, }; pub use provider::AuctionProvider; @@ -45,66 +44,78 @@ pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, }; -/// Compile the canonical target-independent auction plan for [`Settings`]. +/// Compile the canonical target-independent auction plan for [`Settings`] with +/// the implementations the built-in integrations register. /// -/// This is the single settings-to-plan boundary used by deploy validation, -/// adapter startup, and operator tooling. Global request signing remains owned -/// by [`Settings`] and is copied into compiler input only at this boundary. +/// This is the settings-to-plan boundary used by deploy validation, adapter +/// startup and operator tooling. Global request signing remains owned by +/// [`Settings`] and is copied into compiler input only at this boundary. /// /// # Errors /// -/// Returns an error when auction provider, bidder route, signing, or mediator +/// Returns an error when the `[demand]`, `[adserver]`, bidder route or signing /// configuration is invalid. pub fn compile_auction_plan( settings: &Settings, ) -> Result> { + compile_auction_plan_with(settings, &[]) +} + +/// Compile the auction plan with the implementations the built-in integrations +/// register followed by those the `extra` builders register, the builders an +/// adapter or a vendor crate supplies. +/// +/// # Errors +/// +/// Returns an error when the `[demand]`, `[adserver]`, bidder route or signing +/// configuration is invalid, including a selected name whose implementation no +/// builder registers. +pub fn compile_auction_plan_with( + settings: &Settings, + extra: &[IntegrationBuilder], +) -> Result> { + let builders = crate::integrations::all_builders(extra).collect::>(); AuctionPlan::compile(plan::AuctionPlanConfig { timeout_ms: settings.auction.timeout_ms, - providers: settings.auction.providers.clone(), + demand: settings.demand.clone(), + adserver: settings.adserver.clone(), bidders: settings.auction.bidders.clone(), - mediator: settings.auction.mediator.clone(), request_signing: settings.request_signing.clone(), + demand_implementations: builders + .iter() + .filter_map(IntegrationBuilder::demand) + .collect(), + adserver_implementations: builders + .iter() + .filter_map(IntegrationBuilder::adserver) + .collect(), }) .map(|plan| plan.with_enabled(settings.auction.enabled)) } /// Build a new auction orchestrator from one shared compiled plan. /// -/// This constructor registers all auction providers discovered from the provided settings. -/// Callers can reuse the returned [`AuctionOrchestrator`] across requests. -/// -/// # Arguments -/// * `plan` - Shared immutable compiled plan -/// * `settings` - Application settings used only for the separately registered mediator +/// The demand sources come from the plan, and so does the ad server, which is +/// built here from its implementation and settings. Callers can reuse the +/// returned [`AuctionOrchestrator`] across requests. /// /// # Errors /// -/// Returns an error when an enabled auction provider has invalid configuration. +/// Returns an error when the selected ad server cannot be built from its +/// settings. pub fn build_orchestrator_with_plan( plan: Arc, - settings: &Settings, ) -> Result> { log::info!("Building plan-backed auction orchestrator"); - let mediator = if let Some(expected_id) = plan.mediator() { - let provider = crate::integrations::adserver_mock::register_providers(settings)? - .into_iter() - .find(|provider| provider.provider_name() == expected_id) - .ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: format!( - "auction mediator `{expected_id}` must reference a separately registered enabled integration with the exact same ID" - ), - }) - })?; - Some(provider) - } else { - None - }; - let orchestrator = AuctionOrchestrator::from_plan(plan, mediator); + let adserver = plan + .adserver() + .map(|adserver| (adserver.implementation.build)(adserver.id.as_str(), &adserver.settings)) + .transpose()?; + let orchestrator = AuctionOrchestrator::from_plan(plan, adserver); log::info!( - "Auction orchestrator built with {} bidder providers", + "Auction orchestrator built with {} demand sources", orchestrator.provider_count() ); @@ -115,27 +126,39 @@ pub fn build_orchestrator_with_plan( /// /// # Errors /// -/// Returns an error when plan compilation or mediator construction fails. +/// Returns an error when plan compilation or ad server construction fails. #[cfg(test)] pub fn build_orchestrator( settings: &Settings, ) -> Result> { let plan = Arc::new(compile_auction_plan(settings)?); - build_orchestrator_with_plan(plan, settings) + build_orchestrator_with_plan(plan) } #[cfg(test)] mod plan_sharing_tests { use super::*; + use crate::auction::test_support::{demand_named, demand_selection, demand_table}; use crate::integrations::IntegrationRegistry; + use crate::provider_table::ProviderChoice; use crate::test_support::tests::create_test_settings; + use serde_json::{Map, json}; + use std::collections::BTreeMap; + + /// An `[adserver]` table selecting one name with the settings given. + fn adserver(name: &str, settings: Map) -> ProviderChoice { + ProviderChoice::new( + Some(name.to_string()), + BTreeMap::from([(name.to_string(), settings)]), + ) + } #[test] fn orchestrator_and_registry_share_the_compiled_plan_allocation() { let settings = create_test_settings(); let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); - let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings) - .expect("should build orchestrator"); + let orchestrator = + build_orchestrator_with_plan(Arc::clone(&plan)).expect("should build orchestrator"); let registry = IntegrationRegistry::with_plan(&settings, Arc::clone(&plan)) .expect("should build integration registry"); @@ -144,54 +167,50 @@ mod plan_sharing_tests { } #[test] - fn configured_mediator_requires_enabled_exact_registration() { - for mediator_config in [None, Some(serde_json::json!({"enabled": false}))] { - let mut settings = create_test_settings(); - settings.auction.mediator = Some("adserver_mock".to_string()); - if let Some(config) = mediator_config { - settings - .integrations - .insert_config("adserver_mock", &config) - .expect("should insert mediator config"); - } else { - settings.integrations.remove("adserver_mock"); - } - let plan = Arc::new(compile_auction_plan(&settings).expect("should compile plan")); - - let error = match build_orchestrator_with_plan(plan, &settings) { - Ok(_) => panic!("should require enabled mediator registration"), - Err(error) => error, - }; - assert!(error.to_string().contains("adserver_mock")); - } + fn an_ad_server_this_build_does_not_have_fails_the_plan() { + let mut settings = create_test_settings(); + settings.adserver = + ProviderChoice::new(Some("fictional_adserver".to_string()), BTreeMap::new()); + let error = compile_auction_plan(&settings) + .expect_err("should refuse an ad server no builder registers"); + assert!( + error.to_string().contains("fictional_adserver"), + "should name the ad server: {error:?}" + ); } #[test] - fn configured_mediator_builds_when_exact_registration_is_enabled() { + fn the_selected_ad_server_is_built_from_its_own_table() { let mut settings = create_test_settings(); - settings.auction.mediator = Some("adserver_mock".to_string()); - settings - .integrations - .insert_config( - "adserver_mock", - &serde_json::json!({ - "enabled": true, - "endpoint": "https://mediator.example/mediate" - }), - ) - .expect("should insert mediator config"); + settings.adserver = adserver( + "adserver_mock", + Map::from_iter([( + "endpoint".to_string(), + json!("https://adserver.example/mediate"), + )]), + ); let plan = Arc::new(compile_auction_plan(&settings).expect("should compile plan")); - build_orchestrator_with_plan(plan, &settings) - .expect("should build with enabled exact mediator registration"); + build_orchestrator_with_plan(plan).expect("should build the selected ad server"); + } + + #[test] + fn an_ad_server_table_with_no_endpoint_fails_the_plan() { + let mut settings = create_test_settings(); + settings.adserver = adserver("adserver_mock", Map::new()); + let error = compile_auction_plan(&settings) + .expect_err("should refuse an ad server with no endpoint"); + assert!( + format!("{error:?}").contains("endpoint"), + "should say an endpoint is needed: {error:?}" + ); } #[test] fn cloudflare_and_spin_reject_multi_provider_plans_before_runtime_construction() { let mut settings = create_test_settings(); settings.auction.enabled = true; - settings.auction.providers = - AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]); + settings.demand = demand_named(&["provider_a", "provider_b"]); let plan = compile_auction_plan(&settings).expect("should compile target-independent plan"); for target in [ @@ -210,24 +229,38 @@ mod plan_sharing_tests { } #[test] - fn aps_profile_registers_renderer_without_browser_aps_config() { + fn an_aps_demand_source_registers_the_renderer_with_no_integration_table() { let mut settings = create_test_settings(); - settings.auction.providers = std::collections::BTreeMap::from([( - "aps-main".parse().expect("should parse APS provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "aps".to_string(), - endpoint: "https://aps.example/e/pb/bid".to_string(), - timeout_ms: None, - routing: RoutingMode::AllEligible, - notifications: NotificationConfig::default(), - profile_config: serde_json::json!({"account_id":"example-account"}), - }, - )]); + let mut table = demand_table("aps", "https://aps.example/e/pb/bid"); + table.insert("routing".to_string(), json!("all_eligible")); + settings.demand = demand_selection(vec![("aps_main", table)]); let plan = Arc::new(compile_auction_plan(&settings).expect("should compile APS plan")); let registry = IntegrationRegistry::with_plan(&settings, plan) .expect("should build APS renderer registry"); assert!(registry.has_route(&http::Method::GET, "/integrations/aps/renderer")); } + + #[test] + fn two_aps_sources_that_disagree_on_rendering_are_refused() { + let mut settings = create_test_settings(); + let mut publisher_native = demand_table("aps", "https://aps.example/e/pb/bid"); + publisher_native.insert("rendering_mode".to_string(), json!("publisher_native")); + settings.demand = demand_selection(vec![ + ( + "aps_one", + demand_table("aps", "https://aps.example/e/pb/bid"), + ), + ("aps_two", publisher_native), + ]); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile APS plan")); + let error = match IntegrationRegistry::with_plan(&settings, plan) { + Ok(_) => panic!("should refuse two rendering modes"), + Err(error) => error, + }; + assert!( + error.to_string().contains("rendering_mode"), + "should name the setting that disagrees: {error:?}" + ); + } } diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index cf657a1ec..c681524f9 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -1,23 +1,22 @@ -//! Shared `OpenRTB` 2.6 request/response support for config-first providers. +//! The shared `OpenRTB` 2.6 request and response driver. //! -//! Profiles receive only routed, privacy-approved facts and never the raw -//! downstream request or unrestricted runtime services. +//! 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; use serde_json::{Map, Value, json}; -use url::Url; -use super::plan::{NotificationPolicy, ProviderPlan}; -use super::profile::{ - ApsProfilePlan, CompiledOpenRtbProfile, PrebidProfilePlan, StandardProfilePlan, -}; -use super::routing::{ - PrebidTransportHeaders, ProviderAuctionInput, ProviderSlotInput, RoutedAuction, +use super::demand::{ + CompiledDemand, DemandFieldPolicy, ImpressionExtension, RegsPolicy, RequestExtensions, }; +use super::plan::{NotificationPolicy, ProviderPlan}; +use super::routing::{ProviderAuctionInput, ProviderSlotInput, RoutedAuction, TransportHeaders}; use super::types::{AdFormat, AuctionResponse, Bid}; -use crate::consent::ConsentSource; use crate::error::TrustedServerError; use crate::openrtb::{ Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, @@ -26,9 +25,8 @@ use crate::openrtb::{ use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; const DEFAULT_CURRENCY: &str = "USD"; -const APS_SDK_SOURCE: &str = "prebid"; -const APS_SDK_VERSION: &str = "2.2.0"; -const MAX_CONSERVATIVE_LANGUAGE_BYTES: usize = 8; +/// 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. #[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] @@ -244,12 +242,13 @@ pub(crate) struct RequestFinalization<'a> { pub(crate) signing_params: SigningParams, } -/// Build one provider request from its immutable routed input. +/// Build one demand source's request from its immutable routed input. /// /// # Errors /// -/// Returns an auction error when static/profile extensions cannot be merged or -/// the supplied signing input does not bind the already-fixed request ID. +/// 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, @@ -257,98 +256,74 @@ pub(crate) fn build_request( effective_timeout_ms: u32, finalization: &RequestFinalization<'_>, ) -> Result> { - let policy = ProfilePolicy::from(&provider.profile); - let mut request = build_common_request(input, routed, policy, effective_timeout_ms); + 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); } - policy.augment_request(&mut request, input, routed)?; - finalize_request(&mut request, policy, finalization)?; - Ok(OpenRtbBuildOutcome::Ready(request)) -} - -#[derive(Clone, Copy)] -enum ProfilePolicy<'a> { - Standard(&'a StandardProfilePlan), - Prebid(&'a PrebidProfilePlan), - Aps(&'a ApsProfilePlan), -} - -impl<'a> From<&'a CompiledOpenRtbProfile> for ProfilePolicy<'a> { - fn from(profile: &'a CompiledOpenRtbProfile) -> Self { - match profile { - CompiledOpenRtbProfile::Standard(plan) => Self::Standard(plan), - CompiledOpenRtbProfile::PrebidServer(plan) => Self::Prebid(plan), - CompiledOpenRtbProfile::Aps(plan) => Self::Aps(plan), - } - } -} - -impl ProfilePolicy<'_> { - fn augment_request( - self, - request: &mut OpenRtbRequest, - input: &ProviderAuctionInput, - routed: &RoutedAuction, - ) -> Result<(), Report> { - match self { - Self::Standard(plan) => apply_standard(request, plan), - Self::Prebid(plan) => apply_prebid(request, input, routed, plan), - Self::Aps(plan) => apply_aps(request, plan), - } + { + // 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, + }) + .collect(); + let mut extensions = RequestExtensions { + request: ext, + impressions, + }; + demand.augment_request(&mut extensions, input)?; } - - fn keeps_pbs_identity_when_unsigned(self) -> bool { - matches!(self, Self::Prebid(_)) + 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, - policy: ProfilePolicy<'_>, + demand: &dyn CompiledDemand, + policy: DemandFieldPolicy, effective_timeout_ms: u32, -) -> OpenRtbRequest { +) -> (OpenRtbRequest, Vec) { let common = input.common_request(); - let imps = input + let (slot_indices, imps): (Vec, Vec) = input .slots() .iter() - .filter_map(|slot| build_imp(slot, policy)) - .collect(); - let site_domain = match policy { - ProfilePolicy::Aps(plan) => plan - .inventory_domain - .clone() - .unwrap_or_else(|| common.publisher.domain.clone()), - _ => common.publisher.domain.clone(), - }; - let page = match policy { - ProfilePolicy::Aps(plan) => { - aps_inventory_page(plan, common.publisher.page_url.as_deref(), &site_domain) - } - ProfilePolicy::Prebid(plan) => common.publisher.page_url.as_deref().map(|page| { - plan.debug_query_params.as_deref().map_or_else( - || page.to_string(), - |query| append_query_fragment(page, query), - ) - }), - ProfilePolicy::Standard(_) => common.publisher.page_url.clone(), - }; - let consent = common.user.consent.as_ref(); - let body_consent = match policy { - ProfilePolicy::Prebid(plan) => consent.filter(|value| { - plan.consent_forwarding.includes_body_consent() - || !matches!(value.source, ConsentSource::Cookie) - }), - _ => consent, - }; + .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: matches!(policy, ProfilePolicy::Prebid(_)) + consented_providers_settings: policy + .additional_consent .then(|| { body_consent .and_then(|value| value.raw_ac_string.clone()) @@ -362,7 +337,7 @@ fn build_common_request( .to_ext(), ..Default::default() }); - let language = normalized_language(routed.prebid_transport_headers(), policy); + let language = normalized_language(routed.transport_headers(), policy); let device = common .device .as_ref() @@ -373,8 +348,8 @@ fn build_common_request( country: Some(geo.country.clone()), region: geo.region.clone(), city: Some(geo.city.clone()), - lat: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.latitude), - lon: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.longitude), + 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() @@ -391,14 +366,15 @@ fn build_common_request( }) }); - OpenRtbRequest { + let request = OpenRtbRequest { id: Some(common.id.clone()), imp: imps, site: Some(Site { domain: Some(site_domain.clone()), page, - r#ref: matches!(policy, ProfilePolicy::Prebid(_)) - .then(|| header_string(routed.prebid_transport_headers().referer())) + r#ref: policy + .site_ref + .then(|| header_string(routed.transport_headers().referer())) .flatten(), publisher: Some(Publisher { domain: Some(site_domain), @@ -408,11 +384,8 @@ fn build_common_request( }), user, device, - regs: build_regs(body_consent, policy), - test: match policy { - ProfilePolicy::Prebid(plan) => plan.test_mode.then_some(true), - _ => None, - }, + regs: build_regs(body_consent, policy.regs), + test: policy.test.then_some(true), tmax: to_openrtb_i32( effective_timeout_ms, "tmax", @@ -420,10 +393,11 @@ fn build_common_request( ), cur: vec![DEFAULT_CURRENCY.to_string()], ..Default::default() - } + }; + (request, slot_indices) } -fn build_imp(slot: &ProviderSlotInput, policy: ProfilePolicy<'_>) -> Option { +fn build_imp(slot: &ProviderSlotInput, policy: DemandFieldPolicy) -> Option { let formats = slot .slot() .formats @@ -439,17 +413,17 @@ fn build_imp(slot: &ProviderSlotInput, policy: ProfilePolicy<'_>) -> Option .collect::>(); let first_width = formats.first()?.w; let first_height = formats.first()?.h; - let aps_banner = matches!(policy, ProfilePolicy::Aps(_)); + let primary_size = policy.primary_banner_size; Some(Imp { id: Some(slot.slot().id.clone()), banner: Some(Banner { format: formats, - w: aps_banner.then_some(first_width).flatten(), - h: aps_banner.then_some(first_height).flatten(), - topframe: aps_banner.then_some(false), + 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: matches!(policy, ProfilePolicy::Prebid(_)).then(|| slot.slot().id.clone()), + tagid: policy.imp_tagid.then(|| slot.slot().id.clone()), bidfloor: slot.slot().floor_price, bidfloorcur: slot .slot() @@ -460,89 +434,9 @@ fn build_imp(slot: &ProviderSlotInput, policy: ProfilePolicy<'_>) -> Option }) } -fn apply_standard( - request: &mut OpenRtbRequest, - plan: &StandardProfilePlan, -) -> Result<(), Report> { - request.ext = nonempty_map(plan.request_ext.as_object().clone()); - for imp in &mut request.imp { - imp.ext = nonempty_map(plan.imp_ext.as_object().clone()); - } - Ok(()) -} - -fn apply_prebid( - request: &mut OpenRtbRequest, - input: &ProviderAuctionInput, - _routed: &RoutedAuction, - plan: &PrebidProfilePlan, -) -> Result<(), Report> { - debug_assert_eq!( - request.imp.len(), - input.slots().len(), - "should keep one impression per routed slot" - ); - for (imp, slot) in request.imp.iter_mut().zip(input.slots()) { - let bidder = slot - .bidder_params() - .iter() - .filter_map(|(bidder, params)| { - let mut params = params.clone(); - plan.override_engine - .apply_routed(bidder.as_str(), slot.prebid_zone(), &mut params); - params - .as_object() - .is_some_and(|params| !params.is_empty()) - .then(|| (bidder.as_str().to_string(), params)) - }) - .collect::>(); - let mut prebid = Map::new(); - if !bidder.is_empty() { - prebid.insert("bidder".to_string(), Value::Object(bidder)); - } else if slot.has_trusted_stored_request() || !slot.bidder_params().is_empty() { - prebid.insert("storedrequest".to_string(), json!({"id": slot.slot().id})); - } - debug_assert!( - !prebid.is_empty(), - "should never route a demandless slot to prebid-server" - ); - imp.ext = Some(Map::from_iter([( - "prebid".to_string(), - Value::Object(prebid), - )])); - } - let mut prebid_request = Map::new(); - if plan.debug { - prebid_request.insert("debug".to_string(), Value::Bool(true)); - prebid_request.insert("returnallbidstatus".to_string(), Value::Bool(true)); - } - request.ext = Some(Map::from_iter([( - "prebid".to_string(), - Value::Object(prebid_request), - )])); - Ok(()) -} - -fn apply_aps( - request: &mut OpenRtbRequest, - plan: &ApsProfilePlan, -) -> Result<(), Report> { - request.ext = Some(Map::from_iter([ - ( - "account".to_string(), - Value::String(plan.account_id.clone()), - ), - ( - "sdk".to_string(), - json!({"source": APS_SDK_SOURCE, "version": APS_SDK_VERSION}), - ), - ])); - Ok(()) -} - fn finalize_request( request: &mut OpenRtbRequest, - policy: ProfilePolicy<'_>, + policy: DemandFieldPolicy, finalization: &RequestFinalization<'_>, ) -> Result<(), Report> { let request_id = request.id.as_deref().ok_or_else(|| { @@ -565,7 +459,7 @@ fn finalize_request( request_scheme: Some(finalization.signing_params.request_scheme.clone()), ts: Some(finalization.signing_params.timestamp), }) - } else if policy.keeps_pbs_identity_when_unsigned() { + } else if policy.unsigned_request_identity { Some(TrustedServerExt { version: None, signature: None, @@ -584,19 +478,19 @@ fn finalize_request( message: format!("Failed to serialize Trusted Server extension: {error}"), }) })?; - ext.insert("trusted_server".to_string(), serialized); + ext.insert(TRUSTED_SERVER_EXT_KEY.to_string(), serialized); } Ok(()) } fn build_regs( consent: Option<&crate::consent::ConsentContext>, - policy: ProfilePolicy<'_>, + policy: RegsPolicy, ) -> Option { let consent = consent?; - if matches!(policy, ProfilePolicy::Aps(_)) { - // Preserve APS exactly: any admitted context produces regs and GDPR is - // derived only from the applicability bit, without jurisdiction rules. + 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(), @@ -617,8 +511,8 @@ fn build_regs( }); } - // Standard deliberately shares PBS's conservative consent baseline. Keep - // the legacy PBS empty-context and jurisdiction behavior byte-for-byte. + // 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() @@ -662,21 +556,16 @@ fn build_regs( }) } -fn normalized_language( - headers: &PrebidTransportHeaders, - policy: ProfilePolicy<'_>, -) -> Option { +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 { - ProfilePolicy::Prebid(_) => Some(value), - ProfilePolicy::Aps(_) | ProfilePolicy::Standard(_) => { - (value.len() <= MAX_CONSERVATIVE_LANGUAGE_BYTES).then_some(value) - } + match policy.language_max_bytes { + None => Some(value), + Some(max_bytes) => (value.len() <= max_bytes).then_some(value), } } @@ -686,52 +575,6 @@ fn header_string(value: Option<&http::HeaderValue>) -> Option { .map(str::to_string) } -fn aps_inventory_page( - plan: &ApsProfilePlan, - publisher_page: Option<&str>, - domain: &str, -) -> Option { - let fallback = publisher_page - .and_then(valid_aps_page_url) - .unwrap_or_else(|| format!("https://{domain}")); - let Some(origin) = plan.inventory_page_origin.as_deref() else { - return Some(fallback); - }; - let (Ok(mut canonical), Ok(current)) = (Url::parse(origin), Url::parse(&fallback)) else { - return Some(fallback); - }; - canonical.set_path(current.path()); - canonical.set_query(current.query()); - canonical.set_fragment(None); - Some(canonical.to_string()) -} - -fn append_query_fragment(url: &str, query: &str) -> String { - if query.is_empty() || url.contains(query) { - return url.to_string(); - } - let separator = if url.contains('?') { '&' } else { '?' }; - format!("{url}{separator}{query}") -} - -fn valid_aps_page_url(value: &str) -> Option { - const MAX_APS_PAGE_URL_BYTES: usize = 8192; - - if value.len() > MAX_APS_PAGE_URL_BYTES { - return None; - } - let parsed = Url::parse(value).ok()?; - (matches!(parsed.scheme(), "http" | "https") - && parsed.host_str().is_some() - && parsed.username().is_empty() - && parsed.password().is_none()) - .then(|| parsed.to_string()) -} - -fn nonempty_map(value: Map) -> Option> { - (!value.is_empty()).then_some(value) -} - /// Suppress notification URLs using exact returned-seat identity. pub(crate) fn apply_notification_policy(bids: &mut [Bid], policy: &NotificationPolicy) { for bid in bids { @@ -875,19 +718,19 @@ fn extract_standard_bid( }) } -/// Count bidder parameter objects a profile did not consume. +/// Count bidder parameter objects a demand source did not consume. #[must_use] pub(crate) fn unused_bidder_params_count( - profile: &CompiledOpenRtbProfile, + demand: &dyn CompiledDemand, input: &ProviderAuctionInput, ) -> u32 { - if profile.is_prebid_server() { + if demand.field_policy().consumes_bidder_params { return 0; } ignored_bidder_params_count(input) } -/// Count routed bidder params for a profile known to ignore them. +/// Count routed bidder params for a demand source known to ignore them. #[must_use] pub(crate) fn ignored_bidder_params_count(input: &ProviderAuctionInput) -> u32 { saturating_bidder_param_counts(input.slots().iter().map(|slot| slot.bidder_params().len())) @@ -904,15 +747,12 @@ mod routing_metadata_tests { use std::collections::BTreeMap; use std::str::FromStr as _; - use serde_json::json; - use super::{saturating_bidder_param_counts, unused_bidder_params_count}; - use crate::auction::plan::{ - AuctionPlan, AuctionPlanConfig, BidderId, BidderRouteConfig, NotificationConfig, - ProviderConfig, ProviderId, RoutingMode, - }; + use crate::auction::plan::{AuctionPlan, BidderId, BidderRouteConfig, ProviderId}; use crate::auction::routing::route_auction; - use crate::auction::test_support::canonical_parity_auction_request; + 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() { @@ -925,49 +765,36 @@ mod routing_metadata_tests { } #[test] - fn unused_bidder_param_count_is_profile_aware() { - for (profile, profile_config, expected) in [ - ("prebid-server", json!({}), 0), - ("standard", json!({}), 1), - ("aps", json!({"account_id":"example-account"}), 1), - ] { + fn unused_bidder_param_count_follows_the_implementation() { + for (implementation, expected) in [("prebid_server", 0), ("openrtb", 1), ("aps", 1)] { + let endpoint = if implementation == "aps" { + "https://aps.example/e/pb/bid" + } else { + "https://provider.example/openrtb" + }; let provider_id = - ProviderId::from_str("fictional-provider").expect("should parse provider ID"); - let plan = AuctionPlan::compile(AuctionPlanConfig { - timeout_ms: 1_000, - providers: BTreeMap::from([( - provider_id.clone(), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: profile.to_string(), - endpoint: if profile == "aps" { - "https://aps.example/e/pb/bid".to_string() - } else { - "https://provider.example/openrtb".to_string() - }, - timeout_ms: None, - routing: RoutingMode::Explicit, - notifications: NotificationConfig::default(), - profile_config, - }, - )]), - bidders: BTreeMap::from([( - BidderId::from_str("exampleBidder").expect("should parse bidder ID"), - BidderRouteConfig { - provider: provider_id, - }, - )]), - mediator: None, - request_signing: None, - }) - .expect("should compile profile plan"); + 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 { + provider: 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].profile, &routed.inputs()[0]), + unused_bidder_params_count( + plan.providers()[0].demand.as_ref(), + &routed.inputs()[0] + ), expected, - "{profile} should report only bidder params it ignores" + "{implementation} should report only bidder params it ignores" ); } } diff --git a/crates/trusted-server-core/src/auction/openrtb/test_executor.rs b/crates/trusted-server-core/src/auction/openrtb/test_executor.rs index d322102e6..2f2fa20f8 100644 --- a/crates/trusted-server-core/src/auction/openrtb/test_executor.rs +++ b/crates/trusted-server-core/src/auction/openrtb/test_executor.rs @@ -66,7 +66,7 @@ pub(super) async fn execute_standard_fixture( return Ok( AuctionResponse::no_bid(provider.id.as_str(), 0).with_metadata( "routing", - json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + json!({"unused_bidder_params_count": unused_bidder_params_count(provider.demand.as_ref(), input)}), ), ); } @@ -76,7 +76,7 @@ pub(super) async fn execute_standard_fixture( .with_metadata("http_status", json!(status.as_u16())) .with_metadata( "routing", - json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + json!({"unused_bidder_params_count": unused_bidder_params_count(provider.demand.as_ref(), input)}), )); } let body = response @@ -93,7 +93,7 @@ pub(super) async fn execute_standard_fixture( .with_metadata("error_type", json!("parse_response")) .with_metadata( "routing", - json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + json!({"unused_bidder_params_count": unused_bidder_params_count(provider.demand.as_ref(), input)}), )); } }; @@ -101,7 +101,7 @@ pub(super) async fn execute_standard_fixture( apply_notification_policy(&mut parsed.bids, &provider.notifications); parsed.metadata.insert( "routing".to_string(), - json!({"unused_bidder_params_count": unused_bidder_params_count(&provider.profile, input)}), + 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/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index c2d81088b..dcce1a19d 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -9,69 +9,61 @@ 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, NotificationConfig, - ProviderConfig, ProviderId, RoutingMode, + AuctionPlan, AuctionPlanConfig, BidderId, BidderRouteConfig, ProviderId, }; use crate::auction::provider::{GenericOpenRtbProvider, ProviderRequestOutcome}; use crate::auction::routing::route_auction; -use crate::auction::test_support::canonical_parity_auction_request; +use crate::auction::test_support::{canonical_parity_auction_request, demand_table, plan_config}; use crate::auction::types::{AdFormat, AdSlot, BidStatus, MediaType}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::{ConsentContext, ConsentSource}; -use crate::platform::PlatformHttpClient; use crate::platform::test_support::{ HashMapConfigStore, HashMapSecretStore, NoopHttpClient, StubBackend, StubHttpClient, build_services_with_backend_and_http_client, build_services_with_config_secret_and_http_client, }; +use crate::platform::{PlatformHttpClient, PlatformResponse}; use crate::request_signing::RequestSigner; -fn config(profile: &str, profile_config: Value) -> AuctionPlanConfig { +fn config(implementation: &str, settings: Value) -> AuctionPlanConfig { config_with_endpoint( - profile, - profile_config, + implementation, + settings, "https://exchange.example.test/openrtb", ) } -fn config_with_endpoint(profile: &str, profile_config: Value, endpoint: &str) -> AuctionPlanConfig { - let provider_id = ProviderId::from_str("fictional-provider").expect("should parse provider ID"); - let prebid_server = profile == "prebid-server"; - AuctionPlanConfig { - timeout_ms: 321, - providers: BTreeMap::from([( - provider_id.clone(), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: profile.to_string(), - endpoint: endpoint.to_string(), - timeout_ms: Some(321), - routing: if prebid_server { - RoutingMode::Explicit - } else { - RoutingMode::AllEligible - }, - notifications: NotificationConfig::default(), - profile_config, +fn config_with_endpoint( + implementation: &str, + settings: Value, + endpoint: &str, +) -> AuctionPlanConfig { + let provider_id = ProviderId::from_str("fictional_provider").expect("should parse provider ID"); + let prebid_server = implementation == "prebid_server"; + let mut table = demand_table(implementation, endpoint); + table.insert("timeout_ms".to_string(), json!(321)); + if !prebid_server { + table.insert("routing".to_string(), json!("all_eligible")); + } + if let Value::Object(settings) = settings { + table.extend(settings); + } + let mut config = plan_config(vec![("fictional_provider", table)]); + config.timeout_ms = 321; + if prebid_server { + config.bidders = BTreeMap::from([( + BidderId::from_str("exampleBidder").expect("should parse bidder ID"), + BidderRouteConfig { + provider: provider_id, }, - )]), - bidders: if prebid_server { - BTreeMap::from([( - BidderId::from_str("exampleBidder").expect("should parse bidder ID"), - BidderRouteConfig { - provider: provider_id, - }, - )]) - } else { - BTreeMap::new() - }, - mediator: None, - request_signing: None, + )]); } + config } -fn routed(profile: &str, profile_config: Value) -> (AuctionPlan, RoutedAuction) { - let plan = AuctionPlan::compile(config(profile, profile_config)).expect("should compile plan"); +fn routed(implementation: &str, settings: Value) -> (AuctionPlan, RoutedAuction) { + let plan = AuctionPlan::compile(config(implementation, settings)).expect("should compile plan"); let inbound = Request::builder() .uri("https://publisher.example/auction") .header( @@ -87,12 +79,12 @@ fn routed(profile: &str, profile_config: Value) -> (AuctionPlan, RoutedAuction) } fn routed_with_request( - profile: &str, - profile_config: Value, + implementation: &str, + settings: Value, request: crate::auction::types::AuctionRequest, accept_language: Option<&str>, ) -> (AuctionPlan, RoutedAuction) { - let plan = AuctionPlan::compile(config(profile, profile_config)).expect("should compile plan"); + let plan = AuctionPlan::compile(config(implementation, settings)).expect("should compile plan"); let mut builder = Request::builder().uri("https://publisher.example/auction"); if let Some(language) = accept_language { builder = builder.header(header::ACCEPT_LANGUAGE, language); @@ -105,12 +97,12 @@ fn routed_with_request( } fn build_with_request( - profile: &str, - profile_config: Value, + implementation: &str, + settings: Value, request: crate::auction::types::AuctionRequest, accept_language: Option<&str>, ) -> OpenRtbRequest { - let (plan, routed) = routed_with_request(profile, profile_config, request, accept_language); + let (plan, routed) = routed_with_request(implementation, settings, request, accept_language); match build_request( &routed.inputs()[0], &routed, @@ -137,8 +129,8 @@ fn finalization<'a>(signer: Option<&'a RequestSigner>) -> RequestFinalization<'a } } -fn build(profile: &str, profile_config: Value, signer: Option<&RequestSigner>) -> OpenRtbRequest { - let (plan, routed) = routed(profile, profile_config); +fn build(implementation: &str, settings: Value, signer: Option<&RequestSigner>) -> OpenRtbRequest { + let (plan, routed) = routed(implementation, settings); match build_request( &routed.inputs()[0], &routed, @@ -212,18 +204,19 @@ fn consent_matrix_preserves_pbs_standard_and_aps_policies() { ), ]; for (name, consent) in cases { - for profile in ["standard", "prebid-server", "aps"] { + for implementation in ["openrtb", "prebid_server", "aps"] { let mut canonical = canonical_parity_auction_request(); canonical.user.consent = Some(consent.clone()); - let config = if profile == "aps" { + let config = if implementation == "aps" { json!({"account_id": "example-account-id"}) } else { json!({}) }; - let value = serde_json::to_value(build_with_request(profile, config, canonical, None)) - .expect("should serialize request"); + let value = + serde_json::to_value(build_with_request(implementation, config, canonical, None)) + .expect("should serialize request"); let regs = value.get("regs"); - if profile == "aps" { + if implementation == "aps" { let regs = regs.expect("APS should preserve empty admitted context"); assert_eq!( regs["gdpr"], @@ -231,7 +224,7 @@ fn consent_matrix_preserves_pbs_standard_and_aps_policies() { "{name}" ); } else if name == "empty" { - assert!(regs.is_none(), "{profile} should omit empty regs"); + assert!(regs.is_none(), "{implementation} should omit empty regs"); } else { let regs = regs.expect("should emit actionable regs"); let expected_gdpr = match consent.jurisdiction { @@ -242,7 +235,7 @@ fn consent_matrix_preserves_pbs_standard_and_aps_policies() { assert_eq!( regs.get("gdpr"), expected_gdpr.map(|value| json!(u8::from(value))).as_ref(), - "{name} {profile}" + "{name} {implementation}" ); } let serialized = value.to_string(); @@ -280,7 +273,7 @@ fn pbs_body_consent_respects_source_and_forwarding_mode() { .expect("should have consent context") .source = source; let value = serde_json::to_value(build_with_request( - "prebid-server", + "prebid_server", json!({"consent_forwarding": mode}), canonical, None, @@ -298,18 +291,18 @@ fn pbs_body_consent_respects_source_and_forwarding_mode() { #[test] fn language_limits_are_profile_specific() { let language = "abcdefghijk"; - for (profile, expected) in [ - ("prebid-server", Some(language)), + for (implementation, expected) in [ + ("prebid_server", Some(language)), ("aps", None), - ("standard", None), + ("openrtb", None), ] { - let config = if profile == "aps" { + let config = if implementation == "aps" { json!({"account_id": "example-account-id"}) } else { json!({}) }; let request = build_with_request( - profile, + implementation, config, canonical_parity_auction_request(), Some(language), @@ -319,14 +312,14 @@ fn language_limits_are_profile_specific() { expected ); } - for profile in ["prebid-server", "aps", "standard"] { - let config = if profile == "aps" { + for implementation in ["prebid_server", "aps", "openrtb"] { + let config = if implementation == "aps" { json!({"account_id": "example-account-id"}) } else { json!({}) }; let request = build_with_request( - profile, + implementation, config, canonical_parity_auction_request(), Some("en-US,en;q=0.9"), @@ -370,7 +363,7 @@ fn pbs_debug_query_fragment_preserves_exact_legacy_configured_semantics() { let mut request = canonical_parity_auction_request(); request.publisher.page_url = Some(page.to_string()); let built = build_with_request( - "prebid-server", + "prebid_server", json!({"debug_query_params": fragment}), request, None, @@ -385,8 +378,8 @@ fn pbs_debug_query_fragment_preserves_exact_legacy_configured_semantics() { #[test] fn pbs_routed_overrides_are_ordered_and_stored_request_is_trusted_fallback() { - let mut raw = config( - "prebid-server", + let raw = config( + "prebid_server", json!({ "debug": true, "test_mode": true, @@ -398,16 +391,6 @@ fn pbs_routed_overrides_are_ordered_and_stored_request_is_trusted_fallback() { ] }), ); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); let plan = AuctionPlan::compile(raw).expect("should compile PBS override plan"); let mut request = canonical_parity_auction_request(); request.slots[0].bidders = HashMap::from([( @@ -462,7 +445,7 @@ fn pbs_routed_overrides_are_ordered_and_stored_request_is_trusted_fallback() { assert_eq!( value["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], json!({"generic":1,"ordered":2,"shared":"rule-two","zone":2}), - "should allow profile overrides to populate empty browser params" + "should allow implementation overrides to populate empty browser params" ); let mut stored = canonical_parity_auction_request(); @@ -489,17 +472,7 @@ fn pbs_routed_overrides_are_ordered_and_stored_request_is_trusted_fallback() { #[test] fn pbs_pairs_each_impression_with_its_routed_slot_params() { - let mut raw = config("prebid-server", json!({})); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); + let raw = config("prebid_server", json!({})); let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); let mut request = canonical_parity_auction_request(); request.slots[0].id = "first-slot".to_string(); @@ -548,17 +521,7 @@ fn pbs_pairs_each_impression_with_its_routed_slot_params() { #[test] fn pbs_empty_params_without_matching_override_fall_back_to_stored_request() { - let mut raw = config("prebid-server", json!({})); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); + let raw = config("prebid_server", json!({})); let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); let mut request = canonical_parity_auction_request(); request.slots[0].bidders = HashMap::from([( @@ -592,17 +555,7 @@ fn pbs_empty_params_without_matching_override_fall_back_to_stored_request() { #[test] fn pbs_driver_exact_golden_preserves_profile_policy() { - let mut raw = config("prebid-server", json!({"consent_forwarding": "both"})); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); + let raw = config("prebid_server", json!({"consent_forwarding": "both"})); let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); let mut common = canonical_parity_auction_request(); common.slots[0].bidders = HashMap::from([( @@ -701,15 +654,15 @@ fn aps_driver_exact_golden_preserves_profile_policy() { #[test] fn signing_finalization_is_after_profiles_and_asserts_every_owned_key() { let signer = deterministic_signer(); - for (profile, config) in [ - ("standard", json!({"request_ext": {"fictional": true}})), - ("prebid-server", json!({})), + for (implementation, config) in [ + ("openrtb", json!({"request_ext": {"fictional": true}})), + ("prebid_server", json!({})), ("aps", json!({"account_id": "example-account-id"})), ] { - let unsigned = serde_json::to_value(build(profile, config.clone(), None)) + 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 profile == "prebid-server" { + if implementation == "prebid_server" { assert_eq!( unsigned_ts, Some(&json!({"request_host": "publisher.example", "request_scheme": "https"})), @@ -722,7 +675,7 @@ fn signing_finalization_is_after_profiles_and_asserts_every_owned_key() { ); } - let signed = serde_json::to_value(build(profile, config, Some(&signer))) + let signed = serde_json::to_value(build(implementation, config, Some(&signer))) .expect("should serialize signed request"); let extension = &signed["ext"]["trusted_server"]; assert_eq!(extension["version"], "1.1", "should set signing version"); @@ -750,12 +703,12 @@ fn signed_profiles_and_unsigned_standard_have_exact_full_goldens() { let signer = deterministic_signer(); let cases = [ ( - "standard", + "openrtb", json!({"request_ext": {"fictional": true}}), r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"fictional":true,"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, ), ( - "prebid-server", + "prebid_server", json!({}), r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, ), @@ -765,18 +718,18 @@ fn signed_profiles_and_unsigned_standard_have_exact_full_goldens() { r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}],"w":300,"h":250,"topframe":0},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"account":"example-account-id","sdk":{"source":"prebid","version":"2.2.0"},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, ), ]; - for (profile, config, expected) in cases { + for (implementation, config, expected) in cases { assert_eq!( - serde_json::to_string(&build(profile, config, Some(&signer))) + serde_json::to_string(&build(implementation, config, Some(&signer))) .expect("should serialize signed request"), expected, - "{profile} signed wire fixture should stay exact" + "{implementation} signed wire fixture should stay exact" ); } assert_eq!( serde_json::to_string(&build( - "standard", + "openrtb", json!({"request_ext": {"fictional": true}}), None, )) @@ -789,7 +742,7 @@ fn signed_profiles_and_unsigned_standard_have_exact_full_goldens() { #[test] fn standard_static_extensions_have_no_invented_bidder_param_location() { let request = build( - "standard", + "openrtb", json!({ "request_ext": {"fictional_request": {"enabled": true}}, "imp_ext": {"fictional_imp": "value"} @@ -801,13 +754,13 @@ fn standard_static_extensions_have_no_invented_bidder_param_location() { assert_eq!(value["imp"][0]["ext"]["fictional_imp"], "value"); assert!( !value.to_string().contains("exampleBidder"), - "standard profile must not invent bidder params placement" + "standard implementation must not invent bidder params placement" ); } #[test] fn defensive_no_impression_outcome_does_not_build_transportable_request() { - let (plan, mut routed) = routed("standard", json!({})); + let (plan, mut routed) = routed("openrtb", json!({})); let mut common = routed.inputs()[0].common_request().clone(); common.slots = vec![AdSlot { id: "video-only".to_string(), @@ -835,17 +788,13 @@ fn standard_fixture_with_formats( formats: Vec, ) -> (AuctionPlan, RoutedAuction, OpenRtbRequest) { let mut raw = config( - "standard", + "openrtb", json!({"request_ext": {"fixture": true}, "imp_ext": {"slot_fixture": true}}), ); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), + BidderId::from_str("exampleBidder").expect("should parse bidder"), BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), + provider: ProviderId::from_str("fictional_provider").expect("should parse provider"), }, ); let plan = AuctionPlan::compile(raw).expect("should compile standard fixture plan"); @@ -883,7 +832,7 @@ fn standard_fixture() -> (AuctionPlan, RoutedAuction, OpenRtbRequest) { fn standard_response_extraction_isolates_malformed_siblings_and_ignores_response_id() { let (_plan, routed, _request) = standard_fixture(); let response = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({ "id": "informational-mismatch", @@ -915,7 +864,7 @@ fn standard_response_currency_accepts_omitted_and_usd_but_rejects_other_or_malfo value["cur"] = currency; } let response = - extract_standard_response("fictional-provider", &routed.inputs()[0], &value, 0); + extract_standard_response("fictional_provider", &routed.inputs()[0], &value, 0); assert_eq!( response.status, BidStatus::Success, @@ -926,14 +875,14 @@ 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", &routed.inputs()[0], &eur, 0); assert_eq!(response.status, BidStatus::NoBid); assert_eq!(response.metadata["unsupported_currency"], "EUR"); let mut malformed = bid; malformed["cur"] = json!(["USD"]); let response = - extract_standard_response("fictional-provider", &routed.inputs()[0], &malformed, 0); + extract_standard_response("fictional_provider", &routed.inputs()[0], &malformed, 0); assert_eq!(response.status, BidStatus::Error); assert_eq!(response.metadata["error_type"], "parse_response"); } @@ -942,7 +891,7 @@ fn standard_response_currency_accepts_omitted_and_usd_but_rejects_other_or_malfo fn standard_response_rejects_unknown_impressions_and_dimensions_but_keeps_siblings() { let (_plan, routed, _request) = standard_fixture(); let response = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [{"seat": "seat", "bid": [ {"id":"good","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250}, @@ -972,7 +921,7 @@ fn standard_response_rejects_unknown_impressions_and_dimensions_but_keeps_siblin fn standard_response_infers_only_unambiguous_missing_dimensions() { let (_plan, routed, _request) = standard_fixture(); let inferred = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [{"bid": [ {"id":"inferred","impid":"fictional-slot","price":1.0,"adm":"ok"} @@ -997,7 +946,7 @@ fn standard_response_infers_only_unambiguous_missing_dimensions() { ]; let (_plan, routed, _request) = standard_fixture_with_formats(formats); let ambiguous = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [{"bid": [ {"id":"ambiguous","impid":"fictional-slot","price":1.0,"adm":"ok"} @@ -1032,7 +981,7 @@ fn standard_response_infers_dimensions_from_partial_unique_matches() { let (_plan, routed, _request) = standard_fixture_with_formats(formats); let response = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [{"bid": [ {"id":"width-only","impid":"fictional-slot","price":1.0,"adm":"width","w":300}, @@ -1064,7 +1013,7 @@ fn standard_response_accepts_null_and_integral_float_dimensions() { let (_plan, routed, _request) = standard_fixture(); let response = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [{"bid": [ {"id":"null","impid":"fictional-slot","price":1.0,"adm":"null","w":null,"h":null}, @@ -1114,7 +1063,7 @@ fn standard_response_rejects_partial_dimension_mismatch_and_ambiguity() { let (_plan, routed, _request) = standard_fixture_with_formats(formats); let response = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [{"bid": [ {"id":"ambiguous","impid":"fictional-slot","price":1.0,"adm":"ambiguous","w":300}, @@ -1143,7 +1092,7 @@ fn standard_response_rejects_invalid_numeric_dimension_shapes() { let (_plan, routed, _request) = standard_fixture(); let response = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [{"bid": [ {"id":"fractional","impid":"fictional-slot","price":1.0,"adm":"bad","w":300.5}, @@ -1174,7 +1123,7 @@ fn standard_response_rejects_invalid_numeric_dimension_shapes() { fn notification_suppression_matrix_uses_only_exact_valid_returned_seat() { let (_plan, routed, _request) = standard_fixture(); let response = extract_standard_response( - "fictional-provider", + "fictional_provider", &routed.inputs()[0], &json!({"seatbid": [ {"seat": "exact", "bid": [{"id":"exact","impid":"fictional-slot","price":1.0,"adm":"ok","w":300,"h":250,"nurl":"https://n.example","burl":"https://b.example"}]}, @@ -1312,7 +1261,7 @@ fn fictional_standard_executor_covers_bid_no_bid_malformed_unused_and_redirect() ); let spec = provider.backend_spec(); assert_eq!(spec.host, "exchange.example.test"); - assert_eq!(spec.discriminator.as_deref(), Some("fictional-provider")); + assert_eq!(spec.discriminator.as_deref(), Some("fictional_provider")); }); } @@ -1327,7 +1276,7 @@ fn prebid_endpoint_normalization_reaches_generic_execution_and_preserves_custom_ ("https://pbs.example/bid", "https://pbs.example/bid"), ] { let plan = AuctionPlan::compile(config_with_endpoint( - "prebid-server", + "prebid_server", json!({}), configured_endpoint, )) @@ -1392,7 +1341,94 @@ fn prebid_endpoint_normalization_reaches_generic_execution_and_preserves_custom_ 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); + extract_standard_response("fictional_provider", &routed.inputs()[0], &json!([]), 0); assert_eq!(response.status, BidStatus::Error); assert_eq!(response.metadata["error_type"], "parse_response"); } + +/// A demand implementation that tries to write the extension the driver owns. +struct ForgingDemand; + +#[async_trait::async_trait(?Send)] +impl CompiledDemand for ForgingDemand { + fn field_policy(&self) -> DemandFieldPolicy { + DemandFieldPolicy::default() + } + + fn augment_request( + &self, + extensions: &mut RequestExtensions<'_>, + _input: &ProviderAuctionInput, + ) -> Result<(), Report> { + *extensions.request = Some(serde_json::Map::from_iter([( + "trusted_server".to_string(), + json!({"signature": "forged"}), + )])); + Ok(()) + } + + async fn parse_response( + &self, + context: DemandResponse<'_>, + _response: PlatformResponse, + ) -> Result> { + Ok(AuctionResponse::error( + context.provider_id, + context.response_time_ms, + )) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +#[test] +fn the_driver_refuses_an_implementation_that_claims_the_trusted_server_extension() { + let (plan, routed) = routed("openrtb", json!({})); + let mut provider = plan.providers()[0].clone(); + provider.demand = Arc::new(ForgingDemand); + + let error = match build_request( + &routed.inputs()[0], + &routed, + &provider, + 321, + &finalization(None), + ) { + Ok(_) => panic!("should refuse a forged trusted_server extension"), + Err(error) => error, + }; + + assert!( + error.to_string().contains("trusted_server"), + "should name the extension the driver owns: {error:?}" + ); +} + +#[test] +fn an_implementation_sees_each_impression_beside_the_slot_it_came_from() { + let (plan, routed) = routed("prebid_server", 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 slots = routed.inputs()[0].slots(); + assert_eq!(request.imp.len(), slots.len()); + for (imp, slot) in request.imp.iter().zip(slots) { + assert_eq!(imp.id.as_deref(), Some(slot.slot().id.as_str())); + assert!( + imp.ext.as_ref().and_then(|ext| ext.get("prebid")).is_some(), + "each impression should carry the extension built from its own slot" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 954869e38..e0d334263 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -40,7 +40,7 @@ pub struct DispatchedAuction { timeout_ms: u32, floor_prices: HashMap, provider_request_context: Box>, - /// Carried so the mediator call in collect can pass it as the auction request. + /// Carried so the ad server call in collect can pass it as the auction request. request: AuctionRequest, planned_unused_bidder_params: HashMap, planned_unroutable_bidder_count: u32, @@ -309,7 +309,7 @@ pub struct AuctionOrchestrator { plan_backed: bool, plan: Arc, planned_providers: Vec>, - mediator: Option>, + adserver: Option>, #[cfg(test)] config: AuctionConfig, #[cfg(test)] @@ -321,7 +321,7 @@ pub struct AuctionOrchestrator { pub(crate) struct AuctionOrchestratorHarness { plan: Arc, providers: Vec>, - mediator: Option>, + adserver: Option>, } struct PlannedLaunchState { @@ -338,7 +338,7 @@ struct PlannedLaunchState { impl AuctionOrchestratorHarness { pub(crate) fn new( plan: impl Into>, - mediator: Option>, + adserver: Option>, ) -> Self { let plan = plan.into(); let providers = plan @@ -351,7 +351,7 @@ impl AuctionOrchestratorHarness { Self { plan, providers, - mediator, + adserver, } } @@ -359,8 +359,8 @@ impl AuctionOrchestratorHarness { self.providers.len() } - pub(crate) fn mediator(&self) -> Option<&Arc> { - self.mediator.as_ref() + pub(crate) fn adserver(&self) -> Option<&Arc> { + self.adserver.as_ref() } /// Route and execute config-first bidder providers in deterministic order. @@ -423,11 +423,11 @@ impl AuctionOrchestratorHarness { ( input.provider_id().as_str().to_string(), unused_bidder_params_count( - &self - .plan + self.plan .provider(input.provider_id()) .expect("should find routed provider in compiled plan") - .profile, + .demand + .as_ref(), input, ), ) @@ -642,33 +642,33 @@ impl AuctionOrchestratorHarness { .collect::>(); let helper = AuctionOrchestrator::new(AuctionConfig::default()); let local_winners = || helper.select_winning_bids(&responses, &floor_prices); - let (mediator_response, winning_bids) = if let Some(mediator) = &self.mediator { + let (adserver_response, winning_bids) = if let Some(adserver) = &self.adserver { let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let logical_budget_ms = remaining_ms.min(mediator.timeout_ms()); + let logical_budget_ms = remaining_ms.min(adserver.timeout_ms()); if logical_budget_ms == 0 { log::warn!( - "Auction deadline exhausted before planned mediator; using local ranking" + "Auction deadline exhausted before planned adserver; using local ranking" ); (None, local_winners()) } else { let transport_timeout_ms = context .services .backend() - .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); + .canonicalize_transport_timeout_ms(logical_budget_ms, adserver.timeout_ms()); if transport_timeout_ms == 0 { log::warn!( - "Planned mediator transport budget canonicalized to zero; using local ranking" + "Planned adserver transport budget canonicalized to zero; using local ranking" ); let winning_bids = local_winners(); return Ok(OrchestrationResult { provider_responses: responses, - mediator_response: None, + adserver_response: None, winning_bids, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(routed.diagnostics().unroutable_bidder_count()), }); } - let mediator_context = AuctionContext { + let adserver_context = AuctionContext { settings: context.settings, request: context.request, timeout_ms: logical_budget_ms, @@ -676,9 +676,9 @@ impl AuctionOrchestratorHarness { provider_responses: Some(&responses), services: context.services, }; - let mediator_start = Instant::now(); - let mediated = match mediator - .request_bids(original_request, &mediator_context) + let adserver_start = Instant::now(); + let decided = match adserver + .request_bids(original_request, &adserver_context) .await { Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), @@ -687,30 +687,30 @@ impl AuctionOrchestratorHarness { parse_state, }) => match context.services.http_client().wait(pending).await { Ok(platform_response) => { - let response_time_ms = mediator_start.elapsed().as_millis() as u64; + let response_time_ms = adserver_start.elapsed().as_millis() as u64; if AuctionDeadlinePolicy::for_runtime(context.services) .rejects_late_completion(auction_start, context.timeout_ms) { log::warn!( - "Planned mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", - mediator.provider_name(), + "Planned adserver '{}' completed after the hard auction deadline; using local ranking ({}ms)", + adserver.provider_name(), response_time_ms ); None } else { - mediator + adserver .parse_response_with_context_and_state( platform_response, response_time_ms, original_request, - &mediator_context, + &adserver_context, parse_state.as_deref(), ) .await .map_err(|error| { log::warn!( - "Planned mediator '{}' parse failed: {:?}", - mediator.provider_name(), + "Planned adserver '{}' parse failed: {:?}", + adserver.provider_name(), error ); }) @@ -719,8 +719,8 @@ impl AuctionOrchestratorHarness { } Err(error) => { log::warn!( - "Planned mediator '{}' request failed: {:?}", - mediator.provider_name(), + "Planned adserver '{}' request failed: {:?}", + adserver.provider_name(), error ); None @@ -728,21 +728,21 @@ impl AuctionOrchestratorHarness { }, Err(error) => { log::warn!( - "Planned mediator '{}' failed to launch: {:?}", - mediator.provider_name(), + "Planned adserver '{}' failed to launch: {:?}", + adserver.provider_name(), error ); None } }; - if let Some(mediated) = mediated { - let winners = mediated + if let Some(decided) = decided { + let winners = decided .bids .iter() .filter_map(|bid| { if bid.price.is_none() { log::warn!( - "Planned mediator returned a bid without a decoded price" + "Planned adserver returned a bid without a decoded price" ); None } else { @@ -751,7 +751,7 @@ impl AuctionOrchestratorHarness { }) .collect(); ( - Some(mediated), + Some(decided), helper.apply_floor_prices(winners, &floor_prices), ) } else { @@ -769,7 +769,7 @@ impl AuctionOrchestratorHarness { ); Ok(OrchestrationResult { provider_responses: responses, - mediator_response, + adserver_response, winning_bids, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(unroutable_bidder_count), @@ -785,10 +785,7 @@ impl AuctionOrchestrator { let plan = Arc::new( AuctionPlan::compile(super::plan::AuctionPlanConfig { timeout_ms: config.timeout_ms, - providers: std::collections::BTreeMap::new(), - bidders: std::collections::BTreeMap::new(), - mediator: None, - request_signing: None, + ..super::plan::AuctionPlanConfig::default() }) .expect("should compile empty legacy test plan") .with_enabled(config.enabled), @@ -799,14 +796,14 @@ impl AuctionOrchestrator { config, plan, planned_providers: Vec::new(), - mediator: None, + adserver: None, providers: HashMap::new(), } } /// Create the live orchestrator from one shared compiled auction plan. #[must_use] - pub fn from_plan(plan: Arc, mediator: Option>) -> Self { + pub fn from_plan(plan: Arc, adserver: Option>) -> Self { let planned_providers = plan .providers() .iter() @@ -819,7 +816,7 @@ impl AuctionOrchestrator { plan_backed: true, plan, planned_providers, - mediator, + adserver, #[cfg(test)] config: AuctionConfig::default(), #[cfg(test)] @@ -886,7 +883,7 @@ impl AuctionOrchestrator { /// # Errors /// /// Returns an error if the auction execution fails due to provider errors or - /// mediation errors. + /// ad server decision errors. pub async fn run_auction( &self, request: &AuctionRequest, @@ -904,12 +901,12 @@ impl AuctionOrchestrator { #[cfg(test)] let start_time = Instant::now(); - // Auto-detect strategy based on mediator configuration. + // Auto-detect strategy based on ad server configuration. #[cfg(test)] - let (strategy_name, result) = if self.config.has_mediator() { + let (strategy_name, result) = if self.config.has_adserver() { ( - "parallel_mediation", - self.run_parallel_mediation(request, context).await?, + "parallel_adserver", + self.run_parallel_adserver(request, context).await?, ) } else { ( @@ -920,7 +917,7 @@ impl AuctionOrchestrator { #[cfg(test)] log::info!( - "Running auction with strategy: {} (auto-detected from mediator config)", + "Running auction with strategy: {} (auto-detected from adserver config)", strategy_name ); @@ -931,128 +928,130 @@ impl AuctionOrchestrator { }) } - /// Run auction with parallel bidding + mediation. + /// Run auction with parallel bidding + ad server decision. #[cfg(test)] /// /// Flow: /// 1. Run all bidders in parallel /// 2. Collect bids from all bidders - /// 3. Send combined bids to mediator for final decision - async fn run_parallel_mediation( + /// 3. Send combined bids to ad server for final decision + async fn run_parallel_adserver( &self, request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - let mediation_start = Instant::now(); + let adserver_start = Instant::now(); let provider_responses = self.run_providers_parallel(request, context).await?; let floor_prices = self.floor_prices_by_slot(request); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - let mediator = self.get_provider(mediator_name)?; + let (adserver_response, winning_bids) = if let Some(adserver_name) = + &self.config.adserver_name + { + let adserver = self.get_provider(adserver_name)?; log::info!( - "Sending {} provider responses to mediator: {}", + "Sending {} provider responses to adserver: {}", provider_responses.len(), - mediator.provider_name() + adserver.provider_name() ); - // Give the mediator only the remaining time from the auction + // Give the ad server only the remaining time from the auction // deadline, not the full timeout — the bidding phase already // consumed part of it. Canonicalize the transport timeout so the // backend name remains stable across equivalent budget values. - let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - let mediator_timeout = context + let remaining_ms = remaining_budget_ms(adserver_start, context.timeout_ms); + let adserver_timeout = context .services .backend() - .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); + .canonicalize_transport_timeout_ms(remaining_ms, adserver.timeout_ms()); - if mediator_timeout == 0 { - log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); + if adserver_timeout == 0 { + log::warn!("Auction timeout exhausted during bidding phase; skipping adserver"); let winning = self.select_winning_bids(&provider_responses, &floor_prices); return Ok(OrchestrationResult { provider_responses, - mediator_response: None, + adserver_response: None, winning_bids: winning, total_time_ms: 0, metadata: HashMap::new(), }); } - let mediator_context = AuctionContext { + let adserver_context = AuctionContext { settings: context.settings, request: context.request, - timeout_ms: mediator_timeout, - transport_timeout_ms: mediator_timeout, + timeout_ms: adserver_timeout, + transport_timeout_ms: adserver_timeout, provider_responses: Some(&provider_responses), services: context.services, }; let start_time = Instant::now(); - let mediator_resp = match mediator - .request_bids(request, &mediator_context) + let adserver_resp = match adserver + .request_bids(request, &adserver_context) .await .change_context(TrustedServerError::Auction { - message: format!("Mediator {} failed to launch", mediator.provider_name()), + message: format!("AdServer {} failed to launch", adserver.provider_name()), })? { ProviderRequestOutcome::Immediate(response) => response, ProviderRequestOutcome::Pending { request: pending, parse_state, } => { - let platform_resp = mediator_context + let platform_resp = adserver_context .services .http_client() .wait(pending) .await .change_context(TrustedServerError::Auction { message: format!( - "Mediator {} request failed", - mediator.provider_name() + "AdServer {} request failed", + adserver.provider_name() ), })?; let response_time_ms = start_time.elapsed().as_millis() as u64; if AuctionDeadlinePolicy::for_runtime(context.services) - .rejects_late_completion(mediation_start, context.timeout_ms) + .rejects_late_completion(adserver_start, context.timeout_ms) { log::warn!( - "Mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", - mediator.provider_name(), + "AdServer '{}' completed after the hard auction deadline; using local ranking ({}ms)", + adserver.provider_name(), response_time_ms ); let winning = self.select_winning_bids(&provider_responses, &floor_prices); return Ok(OrchestrationResult { provider_responses, - mediator_response: None, + adserver_response: None, winning_bids: winning, total_time_ms: 0, metadata: HashMap::new(), }); } - mediator + adserver .parse_response_with_context_and_state( platform_resp, response_time_ms, request, - &mediator_context, + &adserver_context, parse_state.as_deref(), ) .await .change_context(TrustedServerError::Auction { - message: format!("Mediator {} parse failed", mediator.provider_name()), + message: format!("AdServer {} parse failed", adserver.provider_name()), })? } }; - // Extract only mediator bids with comparable numeric prices. - let winning = mediator_resp + // Extract only ad server bids with comparable numeric prices. + let winning = adserver_resp .bids .iter() .filter_map(|bid| { if bid.price.is_none() { log::warn!( - "Mediator '{}' returned bid for slot '{}' without a price - skipping", - mediator.provider_name(), + "AdServer '{}' returned bid for slot '{}' without a price - skipping", + adserver.provider_name(), bid.slot_id ); None @@ -1063,25 +1062,25 @@ impl AuctionOrchestrator { .collect(); ( - Some(mediator_resp), + Some(adserver_resp), self.apply_floor_prices(winning, &floor_prices), ) } else { - // No mediator - select best bid per slot from bidder responses + // No ad server - select best bid per slot from bidder responses let winning = self.select_winning_bids(&provider_responses, &floor_prices); (None, winning) }; Ok(OrchestrationResult { provider_responses, - mediator_response, + adserver_response, winning_bids, total_time_ms: 0, // Will be set by caller metadata: HashMap::new(), }) } - /// Run auction with only parallel bidding (no mediation). + /// Run auction with only parallel bidding (no ad server decision). #[cfg(test)] async fn run_parallel_only( &self, @@ -1094,7 +1093,7 @@ impl AuctionOrchestrator { Ok(OrchestrationResult { provider_responses, - mediator_response: None, + adserver_response: None, winning_bids, total_time_ms: 0, metadata: HashMap::new(), @@ -1113,9 +1112,9 @@ impl AuctionOrchestrator { ) -> Result, Report> { let provider_names = self .config - .providers - .keys() - .map(super::plan::ProviderId::as_str) + .provider_names + .iter() + .map(String::as_str) .collect::>(); if provider_names.is_empty() { @@ -1616,10 +1615,10 @@ impl AuctionOrchestrator { ( input.provider_id().as_str().to_string(), unused_bidder_params_count( - &plan - .provider(input.provider_id()) + plan.provider(input.provider_id()) .expect("should find routed provider in compiled plan") - .profile, + .demand + .as_ref(), input, ), ) @@ -1648,10 +1647,10 @@ impl AuctionOrchestrator { materialize_planned_response( provider_launch_failed_response(input.provider_id().as_str(), 0), unused_bidder_params_count( - &plan - .provider(input.provider_id()) + plan.provider(input.provider_id()) .expect("should find routed provider in compiled plan") - .profile, + .demand + .as_ref(), input, ), ) @@ -1859,9 +1858,9 @@ impl AuctionOrchestrator { #[cfg(test)] let provider_names = self .config - .providers - .keys() - .map(super::plan::ProviderId::as_str) + .provider_names + .iter() + .map(String::as_str) .collect::>(); #[cfg(test)] if provider_names.is_empty() { @@ -2085,7 +2084,7 @@ impl AuctionOrchestrator { /// Collect bid responses from a previously-dispatched auction. /// /// Runs the select-loop phase (equivalent to Phase 2 of - /// `run_providers_parallel`) and, if the orchestrator has a mediator + /// `run_providers_parallel`) and, if the orchestrator has an ad server /// configured, forwards collected bids to it. The overall auction deadline /// is enforced from `dispatched.auction_start`. /// @@ -2292,7 +2291,7 @@ impl AuctionOrchestrator { // collect phase the remaining handles may already be ready even if // wall-clock time elapsed while the origin was slow. Dropping them // here would discard SSP responses that already arrived. The - // mediator launch below still observes A_deadline via + // ad server launch below still observes A_deadline via // `remaining_budget_ms`. } @@ -2329,35 +2328,35 @@ impl AuctionOrchestrator { } #[cfg(not(test))] - let mediator = self.mediator.as_ref(); + let adserver = self.adserver.as_ref(); #[cfg(test)] - let mediator = self.mediator.as_ref().or_else(|| { + let adserver = self.adserver.as_ref().or_else(|| { self.config - .mediator + .adserver_name .as_ref() .and_then(|name| self.providers.get(name)) }); - let (mediator_response, winning_bids) = if let Some(mediator) = mediator { + let (adserver_response, winning_bids) = if let Some(adserver) = adserver { { - // Cap the mediator at whichever is tighter: its own configured + // Cap the ad server at whichever is tighter, being its own configured // timeout or the remaining auction budget (A_deadline). Backend // first-byte and between-bytes timeouts bound normal collection, but // they are transport timers rather than absolute wall-clock limits: // connection setup and byte-trickling can still consume more of the // auction budget. Recomputing the remaining budget here prevents the - // mediator from extending that bounded response hold. + // ad server from extending that bounded response hold. let remaining = remaining_budget_ms(auction_start, timeout_ms); - let logical_budget_ms = remaining.min(mediator.timeout_ms()); + let logical_budget_ms = remaining.min(adserver.timeout_ms()); if logical_budget_ms == 0 { log::warn!( - "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", - mediator.provider_name(), + "A_deadline exhausted before adserver '{}', returning {} SSP bids without an ad server decision", + adserver.provider_name(), responses.len(), ); let winning = self.select_winning_bids(&responses, &floor_prices); return OrchestrationResult { provider_responses: responses, - mediator_response: None, + adserver_response: None, winning_bids: winning, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(planned_unroutable_bidder_count), @@ -2365,43 +2364,43 @@ impl AuctionOrchestrator { } let transport_timeout_ms = services .backend() - .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); + .canonicalize_transport_timeout_ms(logical_budget_ms, adserver.timeout_ms()); if transport_timeout_ms == 0 { log::warn!( - "Mediator '{}' transport budget canonicalized to zero — returning {} SSP bids without mediation", - mediator.provider_name(), + "AdServer '{}' transport budget canonicalized to zero, returning {} SSP bids without an ad server decision", + adserver.provider_name(), responses.len(), ); let winning = self.select_winning_bids(&responses, &floor_prices); return OrchestrationResult { provider_responses: responses, - mediator_response: None, + adserver_response: None, winning_bids: winning, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(planned_unroutable_bidder_count), }; } - let mediator_start = Instant::now(); - // This logs a configured mediator identifier and timeout values, not request data or secrets. + let adserver_start = Instant::now(); + // This logs a configured ad server identifier and timeout values, not request data or secrets. log::info!( - "Running mediator '{}' with {}ms logical budget and {}ms transport timeout (A_deadline remaining: {}ms, configured: {}ms)", - mediator.provider_name(), + "Running adserver '{}' with {}ms logical budget and {}ms transport timeout (A_deadline remaining: {}ms, configured: {}ms)", + adserver.provider_name(), logical_budget_ms, transport_timeout_ms, remaining, - mediator.timeout_ms(), + adserver.timeout_ms(), ); - // The mediator runs on the collect path. See the doc-comment on + // The ad server runs on the collect path. See the doc-comment on // `AuctionContext::request`: the real client request was already // consumed by `send_async` during dispatch, so we substitute a - // canonical placeholder URL. Any future mediator that needs real + // canonical placeholder URL. Any future ad server that needs real // client headers must snapshot them at dispatch time onto // `DispatchedAuction` rather than reading `context.request` here. let placeholder = http::Request::builder() .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) .body(edgezero_core::body::Body::empty()) .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); - let mediator_context = AuctionContext { + let adserver_context = AuctionContext { settings: context.settings, request: &placeholder, timeout_ms: logical_budget_ms, @@ -2409,8 +2408,8 @@ impl AuctionOrchestrator { provider_responses: Some(&responses), services: context.services, }; - let mediator_response = match mediator - .request_bids(&request, &mediator_context) + let adserver_response = match adserver + .request_bids(&request, &adserver_context) .await { Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), @@ -2420,27 +2419,27 @@ impl AuctionOrchestrator { }) => match services.http_client().wait(pending).await.change_context( TrustedServerError::Auction { message: format!( - "Mediator {} request failed", - mediator.provider_name() + "AdServer {} request failed", + adserver.provider_name() ), }, ) { Ok(platform_resp) => { - let response_time_ms = mediator_start.elapsed().as_millis() as u64; + let response_time_ms = adserver_start.elapsed().as_millis() as u64; if deadline_policy.rejects_late_completion(auction_start, timeout_ms) { log::warn!( - "Mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", - mediator.provider_name(), + "AdServer '{}' completed after the hard auction deadline; using local ranking ({}ms)", + adserver.provider_name(), response_time_ms ); None } else { - match mediator + match adserver .parse_response_with_context_and_state( platform_resp, response_time_ms, &request, - &mediator_context, + &adserver_context, parse_state.as_deref(), ) .await @@ -2448,8 +2447,8 @@ impl AuctionOrchestrator { Ok(response) => Some(response), Err(error) => { log::warn!( - "Mediator '{}' parse failed: {:?}", - mediator.provider_name(), + "AdServer '{}' parse failed: {:?}", + adserver.provider_name(), error ); None @@ -2458,29 +2457,29 @@ impl AuctionOrchestrator { } } Err(error) => { - log::warn!("Mediator request failed: {:?}", error); + log::warn!("AdServer request failed: {:?}", error); None } }, Err(error) => { log::warn!( - "Mediator '{}' failed to dispatch: {:?}", - mediator.provider_name(), + "AdServer '{}' failed to dispatch: {:?}", + adserver.provider_name(), error ); None } }; - if let Some(mediator_response) = mediator_response { - let winning = mediator_response + if let Some(adserver_response) = adserver_response { + let winning = adserver_response .bids .iter() .filter_map(|bid| { if bid.price.is_none() { log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", - mediator.provider_name(), + "AdServer '{}' returned bid for slot '{}' without decoded price - skipping", + adserver.provider_name(), bid.slot_id ); None @@ -2490,7 +2489,7 @@ impl AuctionOrchestrator { }) .collect(); let winning = self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_response), winning) + (Some(adserver_response), winning) } else { (None, self.select_winning_bids(&responses, &floor_prices)) } @@ -2501,7 +2500,7 @@ impl AuctionOrchestrator { OrchestrationResult { provider_responses: responses, - mediator_response, + adserver_response, winning_bids, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(planned_unroutable_bidder_count), @@ -2520,8 +2519,8 @@ impl AuctionOrchestrator { pub struct OrchestrationResult { /// All responses from providers pub provider_responses: Vec, - /// Final response from mediator (if used) - pub mediator_response: Option, + /// Final response from ad server (if used) + pub adserver_response: Option, /// Winning bids per slot pub winning_bids: HashMap, /// Total orchestration time in milliseconds @@ -2534,7 +2533,7 @@ impl OrchestrationResult { fn no_bid() -> Self { Self { provider_responses: Vec::new(), - mediator_response: None, + adserver_response: None, winning_bids: HashMap::new(), total_time_ms: 0, metadata: HashMap::new(), @@ -2566,7 +2565,6 @@ impl OrchestrationResult { #[cfg(test)] mod tests { - use std::str::FromStr as _; use std::time::Duration; use base64::Engine as _; @@ -2574,20 +2572,19 @@ mod tests { use crate::auction::config::AuctionConfig; use crate::auction::orchestrator::DispatchAuctionOutcome; - use crate::auction::plan::{ - AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, - }; + use crate::auction::plan::{AuctionPlan, AuctionPlanConfig, NotificationConfig, RoutingMode}; use crate::auction::provider::{ AuctionProvider, GenericOpenRtbProvider, ProviderRequestOutcome, }; use crate::auction::routing::{RoutingDiagnostics, route_auction}; - use crate::auction::test_support::create_test_auction_context; + use crate::auction::test_support::{create_test_auction_context, demand_table, plan_config}; use crate::auction::types::{ - AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidRenderer, + BidStatus, MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; - use crate::integrations::adserver_mock::{AdServerMockConfig, AdServerMockProvider}; + use crate::integrations::adserver_mock::{AdServerMockProvider, AdServerMockSettings}; + use crate::integrations::aps::{APS_RENDERER_TYPE, ApsRendererV1, ApsTagType}; use crate::platform::test_support::{ StubHttpClient, build_services_with_backend_and_http_client, build_services_with_http_client, noop_services, @@ -2609,67 +2606,81 @@ mod tests { ERROR_TYPE_LAUNCH_FAILED, ERROR_TYPE_TIMEOUT, ERROR_TYPE_TRANSPORT, OrchestrationResult, }; - fn planned_config(providers: &[(&str, RoutingMode)], signing: bool) -> AuctionPlanConfig { - AuctionPlanConfig { - timeout_ms: 777, - providers: providers - .iter() - .map(|(id, routing)| { - ( - ProviderId::from_str(id).expect("should parse fictional provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "standard".to_string(), - endpoint: "https://example.test/openrtb".to_string(), - timeout_ms: Some(1_000), - routing: *routing, - notifications: Default::default(), - profile_config: serde_json::json!({}), - }, - ) - }) - .collect(), - bidders: BTreeMap::new(), - mediator: None, - request_signing: signing.then(|| crate::settings::RequestSigning { - enabled: true, - config_store_id: "fictional-config-store".to_string(), - secret_store_id: "fictional-secret-store".to_string(), - }), + /// One `[demand.]` table for a planned test source. + fn planned_table( + implementation: &str, + endpoint: &str, + routing: RoutingMode, + settings: &serde_json::Value, + notifications: &NotificationConfig, + ) -> serde_json::Map { + let mut entry = demand_table(implementation, endpoint); + entry.insert("timeout_ms".to_string(), serde_json::json!(1_000)); + if routing == RoutingMode::AllEligible { + entry.insert("routing".to_string(), serde_json::json!("all_eligible")); } + entry.insert( + "notifications".to_string(), + serde_json::to_value(notifications).expect("should serialize notifications"), + ); + if let serde_json::Value::Object(settings) = settings { + entry.extend(settings.clone()); + } + entry + } + + fn planned_config(providers: &[(&str, RoutingMode)], signing: bool) -> AuctionPlanConfig { + let tables = providers + .iter() + .map(|(id, routing)| { + ( + *id, + planned_table( + "openrtb", + "https://example.test/openrtb", + *routing, + &serde_json::json!({}), + &NotificationConfig::default(), + ), + ) + }) + .collect::>(); + let mut config = plan_config(tables); + config.timeout_ms = 777; + config.request_signing = signing.then(|| crate::settings::RequestSigning { + enabled: true, + config_store_id: "fictional-config-store".to_string(), + secret_store_id: "fictional-secret-store".to_string(), + }); + config } fn planned_prebid_config( providers: &[(&str, serde_json::Value, NotificationConfig)], ) -> AuctionPlanConfig { - AuctionPlanConfig { - timeout_ms: 777, - providers: providers - .iter() - .map(|(id, profile_config, notifications)| { - ( - ProviderId::from_str(id).expect("should parse fictional provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "prebid-server".to_string(), - endpoint: format!("https://{id}.example.test/openrtb"), - timeout_ms: Some(1_000), - routing: RoutingMode::Explicit, - notifications: notifications.clone(), - profile_config: profile_config.clone(), - }, - ) - }) - .collect(), - bidders: BTreeMap::new(), - mediator: None, - request_signing: None, - } + let tables = providers + .iter() + .map(|(id, settings, notifications)| { + ( + *id, + planned_table( + "prebid_server", + &format!("https://{id}.example.test/openrtb"), + RoutingMode::Explicit, + settings, + notifications, + ), + ) + }) + .collect::>(); + let mut config = plan_config(tables); + config.timeout_ms = 777; + config } fn planned_aps_config() -> AuctionPlanConfig { planned_aps_instances_config(&[( - "aps-instance", + "aps_instance", serde_json::json!({"account_id": "example-account"}), NotificationConfig::default(), )]) @@ -2678,29 +2689,24 @@ mod tests { fn planned_aps_instances_config( providers: &[(&str, serde_json::Value, NotificationConfig)], ) -> AuctionPlanConfig { - AuctionPlanConfig { - timeout_ms: 777, - providers: providers - .iter() - .map(|(id, profile_config, notifications)| { - ( - ProviderId::from_str(id).expect("should parse fictional provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "aps".to_string(), - endpoint: "https://aps.example/e/pb/bid".to_string(), - timeout_ms: Some(1_000), - routing: RoutingMode::AllEligible, - notifications: notifications.clone(), - profile_config: profile_config.clone(), - }, - ) - }) - .collect(), - bidders: BTreeMap::new(), - mediator: None, - request_signing: None, - } + let tables = providers + .iter() + .map(|(id, settings, notifications)| { + ( + *id, + planned_table( + "aps", + "https://aps.example/e/pb/bid", + RoutingMode::AllEligible, + settings, + notifications, + ), + ) + }) + .collect::>(); + let mut config = plan_config(tables); + config.timeout_ms = 777; + config } fn planned_request() -> AuctionRequest { @@ -2744,7 +2750,7 @@ mod tests { async fn disabled_from_plan_is_a_no_work_kill_switch_for_sync_and_split_paths() { let plan = Arc::new( AuctionPlan::compile(planned_config( - &[("provider-a", RoutingMode::AllEligible)], + &[("provider_a", RoutingMode::AllEligible)], false, )) .expect("should compile plan") @@ -2815,14 +2821,14 @@ mod tests { async fn all_planned_launch_failures_error_direct_and_surface_split_failure() { let plan = Arc::new( AuctionPlan::compile(planned_config( - &[("launch-fail", RoutingMode::AllEligible)], + &[("launch_fail", RoutingMode::AllEligible)], false, )) .expect("should compile launch-failure plan"), ); let orchestrator = AuctionOrchestrator::from_plan(plan, None); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); - backend.fail_ensure_for("launch-fail"); + backend.fail_ensure_for("launch_fail"); let http = Arc::new(StubHttpClient::new()); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, @@ -2869,7 +2875,7 @@ mod tests { "should keep one launch-failure response" ); assert_eq!( - provider_responses[0].provider, "launch-fail", + provider_responses[0].provider, "launch_fail", "should attribute the response to the failed provider" ); assert_eq!( @@ -2884,7 +2890,7 @@ mod tests { let plan = Arc::new( AuctionPlan::compile(planned_config( &[ - ("launch-fail", RoutingMode::AllEligible), + ("launch_fail", RoutingMode::AllEligible), ("timeout", RoutingMode::AllEligible), ], false, @@ -2921,7 +2927,7 @@ mod tests { ); assert!( result.provider_responses.iter().any(|response| { - response.provider == "launch-fail" + response.provider == "launch_fail" && response.metadata["error_type"] == ERROR_TYPE_LAUNCH_FAILED }), "should retain the launch-failure outcome" @@ -3452,19 +3458,19 @@ mod tests { } } - type RecordedMediatorBudgets = Arc>>; + type RecordedAdServerBudgets = Arc>>; - struct DeadlineRecordingMediator { + struct DeadlineRecordingAdServer { launches: Arc, - budgets: Option, + budgets: Option, } - struct PendingDeadlineMediator; + struct PendingDeadlineAdServer; #[async_trait::async_trait(?Send)] - impl AuctionProvider for PendingDeadlineMediator { + impl AuctionProvider for PendingDeadlineAdServer { fn provider_name(&self) -> &str { - "pending-deadline-mediator" + "pending_deadline_adserver" } async fn request_bids( @@ -3477,8 +3483,8 @@ mod tests { .method("POST") .uri("https://example.com/mediate") .body(edgezero_core::body::Body::empty()) - .expect("should build pending mediator request"), - "pending-mediator-backend", + .expect("should build pending adserver request"), + "pending-adserver-backend", ); context .services @@ -3486,7 +3492,7 @@ mod tests { .send_async(request) .await .change_context(TrustedServerError::Auction { - message: "pending mediator launch failed".to_string(), + message: "pending adserver launch failed".to_string(), }) .map(ProviderRequestOutcome::pending) } @@ -3498,7 +3504,7 @@ mod tests { ) -> Result> { Ok(AuctionResponse::success( self.provider_name(), - vec![auction_bid("mediated", 9.0)], + vec![auction_bid("adserver", 9.0)], response_time_ms, )) } @@ -3508,14 +3514,14 @@ mod tests { } fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some("pending-mediator-backend".to_string()) + Some("pending-adserver-backend".to_string()) } } #[async_trait::async_trait(?Send)] - impl AuctionProvider for DeadlineRecordingMediator { + impl AuctionProvider for DeadlineRecordingAdServer { fn provider_name(&self) -> &str { - "deadline-mediator" + "deadline_adserver" } async fn request_bids( @@ -3527,7 +3533,7 @@ mod tests { if let Some(budgets) = &self.budgets { budgets .lock() - .expect("should lock mediator budgets") + .expect("should lock adserver budgets") .push((context.timeout_ms, context.transport_timeout_ms)); } Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( @@ -3541,7 +3547,7 @@ mod tests { _response: PlatformResponse, _response_time_ms: u64, ) -> Result> { - panic!("immediate mediator response should not be parsed"); + panic!("immediate adserver response should not be parsed"); } fn timeout_ms(&self) -> u32 { @@ -3719,24 +3725,28 @@ mod tests { } } - /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring + /// Ad server whose context-aware parse restores `nurl`/`ad_id` (mirroring /// `adserver_mock`), while its context-free parse does not. Lets a test prove - /// the synchronous mediation path calls `parse_response_with_context`. - struct CacheRestoringMediator; + /// the synchronous ad server decision path calls `parse_response_with_context`. + struct CacheRestoringAdServer; fn auction_bid(bidder: &str, price: f64) -> Bid { let renderer = (bidder == "aps").then(|| { - BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "aps-selected-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - }) + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "aps-selected-bid".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("the APS renderer payload should be a JSON object") }); Bid { slot_id: "slot-1".to_string(), @@ -3763,14 +3773,14 @@ mod tests { } } - fn mediated_bid(nurl: Option) -> Bid { + fn adserver_bid(nurl: Option) -> Bid { Bid { slot_id: "header-banner".to_string(), price: Some(2.5), currency: "USD".to_string(), creative: Some("
ad
".to_string()), adomain: None, - bidder: "mediator".to_string(), + bidder: "adserver".to_string(), returned_seat: None, width: 728, height: 90, @@ -3788,9 +3798,9 @@ mod tests { } #[async_trait::async_trait(?Send)] - impl AuctionProvider for CacheRestoringMediator { + impl AuctionProvider for CacheRestoringAdServer { fn provider_name(&self) -> &str { - "mediator" + "adserver" } async fn request_bids( @@ -3803,8 +3813,8 @@ mod tests { .method("POST") .uri("https://example.com/mediate") .body(edgezero_core::body::Body::empty()) - .expect("should build mediator request"), - "mediator-backend", + .expect("should build adserver request"), + "adserver-backend", ); context .services @@ -3812,7 +3822,7 @@ mod tests { .send_async(req) .await .change_context(TrustedServerError::Auction { - message: "mediator launch failed".to_string(), + message: "adserver launch failed".to_string(), }) .map(ProviderRequestOutcome::pending) } @@ -3824,8 +3834,8 @@ mod tests { ) -> Result> { // Context-free path: cannot restore SSP-only render/accounting fields. Ok(AuctionResponse::success( - "mediator", - vec![mediated_bid(None)], + "adserver", + vec![adserver_bid(None)], response_time_ms, )) } @@ -3839,8 +3849,8 @@ mod tests { ) -> Result> { // Context-aware path: restores nurl/ad_id from the collected SSP bids. Ok(AuctionResponse::success( - "mediator", - vec![mediated_bid(Some("https://nurl.example/win".to_string()))], + "adserver", + vec![adserver_bid(Some("https://nurl.example/win".to_string()))], response_time_ms, )) } @@ -3850,16 +3860,16 @@ mod tests { } fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some("mediator-backend".to_string()) + Some("adserver-backend".to_string()) } } - struct ImmediateMediator; + struct ImmediateAdServer; #[async_trait::async_trait(?Send)] - impl AuctionProvider for ImmediateMediator { + impl AuctionProvider for ImmediateAdServer { fn provider_name(&self) -> &str { - "immediate-mediator" + "immediate_adserver" } async fn request_bids( @@ -3869,7 +3879,7 @@ mod tests { ) -> Result> { Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success( self.provider_name(), - vec![mediated_bid(Some( + vec![adserver_bid(Some( "https://nurl.example/immediate".to_string(), ))], 0, @@ -3881,7 +3891,7 @@ mod tests { _response: PlatformResponse, _response_time_ms: u64, ) -> Result> { - panic!("immediate mediator response should not be parsed"); + panic!("immediate adserver response should not be parsed"); } fn timeout_ms(&self) -> u32 { @@ -3890,22 +3900,22 @@ mod tests { } #[tokio::test] - async fn mediated_bid_preserves_restored_fields_through_run_auction() { - // run_parallel_mediation must parse the mediator response via + async fn adserver_bid_preserves_restored_fields_through_run_auction() { + // run_parallel_adserver must parse the ad server response via // parse_response_with_context so cache/nurl fields restored from SSP - // responses survive the synchronous mediation path (POST /auction, + // responses survive the synchronous ad server decision path (POST /auction, // /_ts/page-bids), matching the dispatched collect path. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); // bidder send_async - stub.push_response(200, b"{}".to_vec()); // mediator send_async + stub.push_response(200, b"{}".to_vec()); // adserver send_async let services = build_services_with_http_client(stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. let services: &'static RuntimeServices = Box::leak(Box::new(services)); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), - mediator: Some("mediator".to_string()), + provider_names: vec!["bidder".to_string()], + adserver_name: Some("adserver".to_string()), timeout_ms: 2000, ..Default::default() }; @@ -3914,7 +3924,7 @@ mod tests { name: "bidder", backend: "bidder-backend", })); - orchestrator.register_provider(Arc::new(CacheRestoringMediator)); + orchestrator.register_provider(Arc::new(CacheRestoringAdServer)); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -3935,34 +3945,34 @@ mod tests { let result = orchestrator .run_auction(&request, &context) .await - .expect("mediated auction should complete"); + .expect("ad server auction should complete"); let bid = result .winning_bids .get("header-banner") - .expect("mediator should produce a winning bid for the slot"); + .expect("adserver should produce a winning bid for the slot"); assert_eq!( bid.nurl.as_deref(), Some("https://nurl.example/win"), - "synchronous mediation must restore nurl via parse_response_with_context" + "the synchronous ad server must restore nurl via parse_response_with_context" ); assert_eq!( bid.ad_id.as_deref(), Some("creative-123"), - "mediated bid must keep its restored ad_id" + "decided bid must keep its restored ad_id" ); } #[tokio::test] - async fn immediate_mediator_completes_in_sync_and_split_paths() { + async fn immediate_adserver_completes_in_sync_and_split_paths() { for split in [false, true] { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), - mediator: Some("immediate-mediator".to_string()), + provider_names: vec!["bidder".to_string()], + adserver_name: Some("immediate_adserver".to_string()), timeout_ms: 2000, ..Default::default() }; @@ -3971,7 +3981,7 @@ mod tests { name: "bidder", backend: "bidder-backend", })); - orchestrator.register_provider(Arc::new(ImmediateMediator)); + orchestrator.register_provider(Arc::new(ImmediateAdServer)); let request = create_test_auction_request(); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); @@ -3997,15 +4007,15 @@ mod tests { orchestrator .run_auction(&request, &context) .await - .expect("auction with immediate mediator should complete") + .expect("auction with immediate adserver should complete") }; assert_eq!( result - .mediator_response + .adserver_response .as_ref() .map(|response| response.provider.as_str()), - Some("immediate-mediator") + Some("immediate_adserver") ); assert_eq!( result @@ -4029,17 +4039,17 @@ mod tests { let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["late-one", "late-two"]), + provider_names: vec!["late_one".to_string(), "late_two".to_string()], timeout_ms: 10, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "late-one", + name: "late_one", backend: "late-one-backend", })); orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "late-two", + name: "late_two", backend: "late-two-backend", })); let request = create_test_auction_request(); @@ -4076,9 +4086,9 @@ mod tests { for split in [false, true] { let result = collect_deadline_test_result(split, false).await; assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "late-one"); + assert_eq!(result.provider_responses[0].provider, "late_one"); assert_eq!(result.provider_responses[0].status, BidStatus::Success); - assert_eq!(result.provider_responses[1].provider, "late-two"); + assert_eq!(result.provider_responses[1].provider, "late_two"); assert_eq!(result.provider_responses[1].status, BidStatus::Success); assert!( result @@ -4088,7 +4098,7 @@ mod tests { "late response times should retain actual elapsed duration" ); assert_eq!( - result.winning_bids["slot-1"].bidder, "late-one", + result.winning_bids["slot-1"].bidder, "late_one", "a completed response remains eligible after the logical deadline" ); } @@ -4108,7 +4118,7 @@ mod tests { } } - async fn pending_mediator_deadline_test_result( + async fn pending_adserver_deadline_test_result( split: bool, enforceable_total_request_deadline: bool, ) -> OrchestrationResult { @@ -4121,8 +4131,8 @@ mod tests { let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["local"]), - mediator: Some("pending-deadline-mediator".to_string()), + provider_names: vec!["local".to_string()], + adserver_name: Some("pending_deadline_adserver".to_string()), timeout_ms: 20, ..Default::default() }; @@ -4131,7 +4141,7 @@ mod tests { name: "local", backend: "local-backend", })); - orchestrator.register_provider(Arc::new(PendingDeadlineMediator)); + orchestrator.register_provider(Arc::new(PendingDeadlineAdServer)); let request = create_test_auction_request(); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); @@ -4162,31 +4172,31 @@ mod tests { } #[tokio::test] - async fn pending_mediator_late_completion_policy_is_equivalent_in_sync_and_split_paths() { + async fn pending_adserver_late_completion_policy_is_equivalent_in_sync_and_split_paths() { for split in [false, true] { - let current = pending_mediator_deadline_test_result(split, false).await; - let current_mediator = current - .mediator_response + let current = pending_adserver_deadline_test_result(split, false).await; + let current_adserver = current + .adserver_response .as_ref() - .expect("current adapters should accept completed late mediator responses"); + .expect("current adapters should accept completed late adserver responses"); assert!( - current_mediator.response_time_ms >= 50, - "mediator timing should preserve actual elapsed duration" + current_adserver.response_time_ms >= 50, + "adserver timing should preserve actual elapsed duration" ); - assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + assert_eq!(current.winning_bids["slot-1"].bidder, "adserver"); - let hard = pending_mediator_deadline_test_result(split, true).await; - assert!(hard.mediator_response.is_none()); + let hard = pending_adserver_deadline_test_result(split, true).await; + assert!(hard.adserver_response.is_none()); assert_eq!(hard.winning_bids["slot-1"].bidder, "local"); assert!( hard.total_time_ms >= 50, - "discarding a late mediator must retain actual total elapsed time" + "discarding a late adserver must retain actual total elapsed time" ); } } #[tokio::test] - async fn split_deadline_skips_mediator_and_falls_back_to_provider_winner() { + async fn split_deadline_skips_adserver_and_falls_back_to_provider_winner() { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); stub.push_select_delay(Duration::from_millis(50)); @@ -4194,17 +4204,17 @@ mod tests { let launches = Arc::new(AtomicUsize::new(0)); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["late-one"]), - mediator: Some("deadline-mediator".to_string()), + provider_names: vec!["late_one".to_string()], + adserver_name: Some("deadline_adserver".to_string()), timeout_ms: 10, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "late-one", + name: "late_one", backend: "late-one-backend", })); - orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { + orchestrator.register_provider(Arc::new(DeadlineRecordingAdServer { launches: Arc::clone(&launches), budgets: None, })); @@ -4229,12 +4239,12 @@ mod tests { .await; assert_eq!(launches.load(Ordering::Relaxed), 0); - assert!(result.mediator_response.is_none()); - assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); + assert!(result.adserver_response.is_none()); + assert_eq!(result.winning_bids["slot-1"].bidder, "late_one"); } #[tokio::test] - async fn synchronous_deadline_skips_mediator_and_falls_back_to_provider_winner() { + async fn synchronous_deadline_skips_adserver_and_falls_back_to_provider_winner() { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); stub.push_select_delay(Duration::from_millis(50)); @@ -4242,17 +4252,17 @@ mod tests { let launches = Arc::new(AtomicUsize::new(0)); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["late-one"]), - mediator: Some("deadline-mediator".to_string()), + provider_names: vec!["late_one".to_string()], + adserver_name: Some("deadline_adserver".to_string()), timeout_ms: 10, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "late-one", + name: "late_one", backend: "late-one-backend", })); - orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { + orchestrator.register_provider(Arc::new(DeadlineRecordingAdServer { launches: Arc::clone(&launches), budgets: None, })); @@ -4273,8 +4283,8 @@ mod tests { .expect("synchronous deadline test should complete"); assert_eq!(launches.load(Ordering::Relaxed), 0); - assert!(result.mediator_response.is_none()); - assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); + assert!(result.adserver_response.is_none()); + assert_eq!(result.winning_bids["slot-1"].bidder, "late_one"); } fn create_test_auction_request() -> AuctionRequest { @@ -4412,7 +4422,7 @@ mod tests { async fn synchronous_auction_accepts_an_all_immediate_no_bid_result() { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["immediate"]), + provider_names: vec!["immediate".to_string()], timeout_ms: 2000, ..Default::default() }; @@ -4437,7 +4447,7 @@ mod tests { async fn split_auction_accepts_an_all_immediate_no_bid_result() { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["immediate"]), + provider_names: vec!["immediate".to_string()], timeout_ms: 2000, ..Default::default() }; @@ -4468,7 +4478,7 @@ mod tests { for split in [false, true] { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["immediate", "pending"]), + provider_names: vec!["immediate".to_string(), "pending".to_string()], timeout_ms: 2000, ..Default::default() }; @@ -4682,7 +4692,7 @@ mod tests { // Timeout paths still need a controllable delayed pending response: // - Deadline check in select() loop (drops remaining requests) - // - Mediator skip when remaining_ms == 0 (bidding exhausts budget) + // - Ad server skip when remaining_ms == 0 (bidding exhausts budget) // - Provider skip when effective_timeout == 0 (budget exhausted before launch) // - Provider context receives reduced timeout_ms per remaining budget // @@ -4696,12 +4706,13 @@ mod tests { enabled: true, sanitize_creatives: true, rewrite_creatives: true, - providers: AuctionConfig::legacy_provider_map(&[]), + provider_names: vec![], bidders: Default::default(), - mediator: None, + adserver_name: None, timeout_ms: 2000, creative_store: "creative_store".to_string(), allowed_context_keys: HashSet::from(["permutive_segments".to_string()]), + ..Default::default() }; let orchestrator = AuctionOrchestrator::new(config); @@ -4728,7 +4739,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["launch-failing"]), + provider_names: vec!["launch_failing".to_string()], timeout_ms: 2000, ..Default::default() }; @@ -4763,17 +4774,17 @@ mod tests { for split in [false, true] { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), + provider_names: vec!["provider_a".to_string(), "provider_b".to_string()], timeout_ms: 2000, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", + name: "provider_a", backend: "shared-backend", })); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", + name: "provider_b", backend: "shared-backend", })); let stub = Arc::new(StubHttpClient::new()); @@ -4801,10 +4812,10 @@ mod tests { }; assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success + response.provider == "provider_a" && response.status == BidStatus::Success })); assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-b" && response.status == BidStatus::Error + response.provider == "provider_b" && response.status == BidStatus::Error })); } } @@ -4878,7 +4889,7 @@ mod tests { let requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), + provider_names: vec!["bidder".to_string()], timeout_ms: 2000, ..Default::default() }); @@ -4922,7 +4933,7 @@ mod tests { let requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), + provider_names: vec!["bidder".to_string()], timeout_ms: 2000, ..Default::default() }); @@ -4948,7 +4959,7 @@ mod tests { } #[test] - fn synchronous_mediation_applies_canonical_timeout_to_mediator() { + fn synchronous_adserver_applies_canonical_timeout_to_adserver() { futures::executor::block_on(async { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); @@ -4965,8 +4976,8 @@ mod tests { let requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), - mediator: Some("mediator".to_string()), + provider_names: vec!["bidder".to_string()], + adserver_name: Some("adserver".to_string()), timeout_ms: 2000, ..Default::default() }); @@ -4975,8 +4986,8 @@ mod tests { backend: "bidder-backend", })); orchestrator.register_provider(Arc::new(recording_provider( - "mediator", - "mediator-backend", + "adserver", + "adserver-backend", 2000, &predicted, &requested, @@ -4988,7 +4999,7 @@ mod tests { orchestrator .run_auction(&create_test_auction_request(), &context) .await - .expect("should complete mediated auction"); + .expect("should complete ad server auction"); assert!(predicted.lock().expect("should lock predicted").is_empty()); assert_eq!(*requested.lock().expect("should lock requested"), vec![500]); @@ -5011,12 +5022,12 @@ mod tests { ); let bidder_predicted = Arc::new(Mutex::new(Vec::new())); let bidder_requested = Arc::new(Mutex::new(Vec::new())); - let mediator_predicted = Arc::new(Mutex::new(Vec::new())); - let mediator_requested = Arc::new(Mutex::new(Vec::new())); + let adserver_predicted = Arc::new(Mutex::new(Vec::new())); + let adserver_requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), - mediator: Some("mediator".to_string()), + provider_names: vec!["bidder".to_string()], + adserver_name: Some("adserver".to_string()), timeout_ms: 2000, ..Default::default() }); @@ -5028,11 +5039,11 @@ mod tests { &bidder_requested, ))); orchestrator.register_provider(Arc::new(recording_provider( - "mediator", - "mediator-backend", + "adserver", + "adserver-backend", 2000, - &mediator_predicted, - &mediator_requested, + &adserver_predicted, + &adserver_requested, ))); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); @@ -5057,20 +5068,20 @@ mod tests { vec![500] ); assert!( - mediator_predicted + adserver_predicted .lock() .expect("should lock predicted") .is_empty() ); assert_eq!( - *mediator_requested.lock().expect("should lock requested"), + *adserver_requested.lock().expect("should lock requested"), vec![500] ); }); } #[test] - fn planned_collect_skips_mediator_with_zero_canonical_transport_budget() { + fn planned_collect_skips_adserver_with_zero_canonical_transport_budget() { futures::executor::block_on(async { let calls = Arc::new(Mutex::new(Vec::new())); let services = build_services_with_backend_and_http_client( @@ -5082,18 +5093,24 @@ mod tests { ); let launches = Arc::new(AtomicUsize::new(0)); let budgets = Arc::new(Mutex::new(Vec::new())); - let plan = AuctionPlan::compile(AuctionPlanConfig { - timeout_ms: 49, - providers: BTreeMap::new(), - bidders: BTreeMap::new(), - mediator: Some("adserver_mock".to_string()), - request_signing: None, - }) - .expect("should compile mediator-only plan") - .with_enabled(true); + let mut adserver_only = plan_config(Vec::new()); + adserver_only.timeout_ms = 49; + adserver_only.adserver = crate::provider_table::ProviderChoice::new( + Some("adserver_mock".to_string()), + BTreeMap::from([( + "adserver_mock".to_string(), + serde_json::Map::from_iter([( + "endpoint".to_string(), + serde_json::json!("https://adserver.example/mediate"), + )]), + )]), + ); + let plan = AuctionPlan::compile(adserver_only) + .expect("should compile adserver-only plan") + .with_enabled(true); let orchestrator = AuctionOrchestrator::from_plan( Arc::new(plan), - Some(Arc::new(DeadlineRecordingMediator { + Some(Arc::new(DeadlineRecordingAdServer { launches: Arc::clone(&launches), budgets: Some(Arc::clone(&budgets)), })), @@ -5119,11 +5136,11 @@ mod tests { assert!( budgets .lock() - .expect("should lock mediator budgets") + .expect("should lock adserver budgets") .is_empty() ); assert_eq!(calls.lock().expect("should lock calls").len(), 1); - assert!(result.mediator_response.is_none()); + assert!(result.adserver_response.is_none()); }); } @@ -5135,12 +5152,12 @@ mod tests { let services = build_services_with_http_client(stub); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["provider-a"]), + provider_names: vec!["provider_a".to_string()], timeout_ms: 2000, ..Default::default() }); orchestrator.register_provider(Arc::new(DivergentBackendProvider { - name: "provider-a", + name: "provider_a", predicted: "predicted-backend", resolved: "resolved-backend", })); @@ -5159,7 +5176,7 @@ mod tests { .await; assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success + response.provider == "provider_a" && response.status == BidStatus::Success })); }); } @@ -5173,17 +5190,17 @@ mod tests { let services = build_services_with_http_client(stub); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), + provider_names: vec!["provider_a".to_string(), "provider_b".to_string()], timeout_ms: 2000, ..Default::default() }); orchestrator.register_provider(Arc::new(DivergentBackendProvider { - name: "provider-a", + name: "provider_a", predicted: "predicted-a", resolved: "shared-resolved", })); orchestrator.register_provider(Arc::new(DivergentBackendProvider { - name: "provider-b", + name: "provider_b", predicted: "predicted-b", resolved: "shared-resolved", })); @@ -5202,10 +5219,10 @@ mod tests { .await; assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success + response.provider == "provider_a" && response.status == BidStatus::Success })); assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-b" && response.status == BidStatus::Error + response.provider == "provider_b" && response.status == BidStatus::Error })); }); } @@ -5228,18 +5245,18 @@ mod tests { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), + provider_names: vec!["provider_a".to_string(), "provider_b".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", + name: "provider_a", backend: "backend-a", })); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", + name: "provider_b", backend: "backend-b", })); @@ -5275,12 +5292,12 @@ mod tests { let provider_a = result .provider_responses .iter() - .find(|r| r.provider == "provider-a") + .find(|r| r.provider == "provider_a") .expect("should have provider-a response"); let provider_b = result .provider_responses .iter() - .find(|r| r.provider == "provider-b") + .find(|r| r.provider == "provider_b") .expect("should have provider-b response"); assert_eq!( @@ -5307,7 +5324,7 @@ mod tests { ); let plan = Arc::new( AuctionPlan::compile(planned_config( - &[("provider-a", RoutingMode::AllEligible)], + &[("provider_a", RoutingMode::AllEligible)], false, )) .expect("should compile planned auction"), @@ -5357,7 +5374,7 @@ mod tests { ); let plan = Arc::new( AuctionPlan::compile(planned_config( - &[("provider-a", RoutingMode::AllEligible)], + &[("provider_a", RoutingMode::AllEligible)], false, )) .expect("should compile planned auction"), @@ -5404,8 +5421,8 @@ mod tests { let plan = Arc::new( AuctionPlan::compile(planned_config( &[ - ("provider-b", RoutingMode::AllEligible), - ("provider-a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), ], false, )) @@ -5441,8 +5458,8 @@ mod tests { .iter() .map(|response| response.provider.as_str()) .collect::>(), - vec!["provider-a", "provider-b"], - "outer select errors should retain deterministic plan order" + vec!["provider_b", "provider_a"], + "outer select errors should keep the order the deployment selected" ); for response in &result.provider_responses { assert_eq!(response.status, BidStatus::Error); @@ -5462,14 +5479,14 @@ mod tests { let services = build_services_with_http_client(stub); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["provider-a"]), + provider_names: vec!["provider_a".to_string()], timeout_ms: 750, - mediator: None, + adserver_name: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", + name: "provider_a", backend: "backend-a", })); let request = create_test_auction_request(); @@ -5542,18 +5559,18 @@ mod tests { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), + provider_names: vec!["provider_a".to_string(), "provider_b".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", + name: "provider_a", backend: "backend-a", })); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", + name: "provider_b", backend: "backend-b", })); @@ -5608,18 +5625,18 @@ mod tests { let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["provider-a", "provider-b"]), + provider_names: vec!["provider_a".to_string(), "provider_b".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", + name: "provider_a", backend: "backend-a", })); orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", + name: "provider_b", backend: "backend-b", })); @@ -5673,13 +5690,13 @@ mod tests { Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, ); - let mut config = planned_config(&[("provider-a", RoutingMode::AllEligible)], false); + let mut config = planned_config(&[("provider_a", RoutingMode::AllEligible)], false); config.bidders.insert( "routed-bidder" .parse() .expect("should parse fictional bidder ID"), crate::auction::plan::BidderRouteConfig { - provider: "provider-a" + provider: "provider_a" .parse() .expect("should parse fictional provider ID"), }, @@ -5725,7 +5742,7 @@ mod tests { assert_eq!(http.recorded_backend_names().len(), 1); assert_eq!(backend.ensured.load(Ordering::Relaxed), 1); assert_eq!(result.provider_responses.len(), 1); - assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!(result.provider_responses[0].provider, "provider_a"); assert_eq!(result.provider_responses[0].status, BidStatus::Success); assert_eq!( result.provider_responses[0].metadata["routing"]["unused_bidder_params_count"], @@ -5743,7 +5760,7 @@ mod tests { } #[tokio::test] - async fn planned_executor_invokes_immediate_mediator_and_applies_floor() { + async fn planned_executor_invokes_immediate_adserver_and_applies_floor() { let http = Arc::new(StubHttpClient::new()); http.push_response( 200, @@ -5761,11 +5778,11 @@ mod tests { Arc::clone(&http) as Arc<_>, ); let plan = AuctionPlan::compile(planned_config( - &[("provider-a", RoutingMode::AllEligible)], + &[("provider_a", RoutingMode::AllEligible)], false, )) .expect("should compile planned auction"); - let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(ImmediateMediator))); + let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(ImmediateAdServer))); let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::new(edgezero_core::body::Body::empty()); @@ -5781,14 +5798,14 @@ mod tests { let result = orchestrator .run_auction(&request, &context) .await - .expect("should execute planned mediation"); + .expect("should execute the planned ad server"); assert_eq!( result - .mediator_response + .adserver_response .as_ref() .map(|response| response.provider.as_str()), - Some("immediate-mediator") + Some("immediate_adserver") ); assert_eq!( result.winning_bids["header-banner"].nurl.as_deref(), @@ -5796,11 +5813,11 @@ mod tests { ); assert!( !result.winning_bids.contains_key("fictional-slot"), - "mediator output owns final selection" + "adserver output owns final selection" ); } - async fn planned_pending_mediator_deadline_result( + async fn planned_pending_adserver_deadline_result( enforceable_total_request_deadline: bool, ) -> OrchestrationResult { let http = Arc::new(StubHttpClient::new()); @@ -5824,12 +5841,12 @@ mod tests { Arc::clone(&http) as Arc<_>, ); let plan = AuctionPlan::compile(planned_config( - &[("provider-a", RoutingMode::AllEligible)], + &[("provider_a", RoutingMode::AllEligible)], false, )) .expect("should compile planned auction"); let orchestrator = - AuctionOrchestratorHarness::new(plan, Some(Arc::new(PendingDeadlineMediator))); + AuctionOrchestratorHarness::new(plan, Some(Arc::new(PendingDeadlineAdServer))); let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::new(edgezero_core::body::Body::empty()); @@ -5845,21 +5862,21 @@ mod tests { orchestrator .run_auction(&request, &context) .await - .expect("should execute planned pending mediator") + .expect("should execute planned pending adserver") } #[tokio::test] - async fn planned_pending_mediator_applies_explicit_hard_deadline_policy() { - let current = planned_pending_mediator_deadline_result(false).await; - let current_mediator = current - .mediator_response + async fn planned_pending_adserver_applies_explicit_hard_deadline_policy() { + let current = planned_pending_adserver_deadline_result(false).await; + let current_adserver = current + .adserver_response .as_ref() - .expect("current adapters should accept completed late mediator responses"); - assert!(current_mediator.response_time_ms >= 50); - assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + .expect("current adapters should accept completed late adserver responses"); + assert!(current_adserver.response_time_ms >= 50); + assert_eq!(current.winning_bids["slot-1"].bidder, "adserver"); - let hard = planned_pending_mediator_deadline_result(true).await; - assert!(hard.mediator_response.is_none()); + let hard = planned_pending_adserver_deadline_result(true).await; + assert!(hard.adserver_response.is_none()); assert_eq!( hard.winning_bids["fictional-slot"].bid_id.as_deref(), Some("provider") @@ -5868,7 +5885,7 @@ mod tests { } #[tokio::test] - async fn planned_executor_mediator_transport_failure_falls_back_locally() { + async fn planned_executor_adserver_transport_failure_falls_back_locally() { let http = Arc::new(StubHttpClient::new()); http.push_response( 200, @@ -5889,12 +5906,12 @@ mod tests { Arc::clone(&http) as Arc<_>, ); let plan = AuctionPlan::compile(planned_config( - &[("provider-a", RoutingMode::AllEligible)], + &[("provider_a", RoutingMode::AllEligible)], false, )) .expect("should compile planned auction"); let orchestrator = - AuctionOrchestratorHarness::new(plan, Some(Arc::new(CacheRestoringMediator))); + AuctionOrchestratorHarness::new(plan, Some(Arc::new(CacheRestoringAdServer))); let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::new(edgezero_core::body::Body::empty()); @@ -5910,9 +5927,9 @@ mod tests { let result = orchestrator .run_auction(&request, &context) .await - .expect("should fall back from mediator transport failure"); + .expect("should fall back from adserver transport failure"); - assert!(result.mediator_response.is_none()); + assert!(result.adserver_response.is_none()); assert_eq!( result.winning_bids["fictional-slot"].bid_id.as_deref(), Some("provider") @@ -5953,11 +5970,11 @@ mod tests { }; let plan = AuctionPlan::compile(planned_prebid_config(&[ ( - "pbs-a", + "pbs_a", serde_json::json!({"debug":true,"test_mode":true,"consent_forwarding":"openrtb_only"}), notifications, ), - ("pbs-b", serde_json::json!({}), NotificationConfig::default()), + ("pbs_b", serde_json::json!({}), NotificationConfig::default()), ])) .expect("should compile planned PBS auction"); let orchestrator = AuctionOrchestratorHarness::new(plan, None); @@ -5991,7 +6008,7 @@ mod tests { assert_eq!(result.provider_responses.len(), 2); let first = &result.provider_responses[0]; - assert_eq!(first.provider, "pbs-a"); + assert_eq!(first.provider, "pbs_a"); assert_eq!(first.bids.len(), 1, "should isolate malformed sibling"); assert_eq!( first.bids[0].returned_seat.as_deref(), @@ -6015,7 +6032,7 @@ mod tests { assert!(first.metadata.contains_key("debug")); assert!(first.metadata.contains_key("bidstatus")); let second = &result.provider_responses[1]; - assert_eq!(second.provider, "pbs-b"); + assert_eq!(second.provider, "pbs_b"); assert_eq!(second.bids[0].returned_seat.as_deref(), Some("keep-seat")); assert!(second.bids[0].nurl.is_some()); assert!(!second.metadata.contains_key("debug")); @@ -6064,7 +6081,7 @@ mod tests { } #[tokio::test] - async fn planned_aps_mock_mediation_preserves_three_identities_and_renderer() { + async fn planned_aps_mock_adserver_preserves_three_identities_and_renderer() { let http = Arc::new(StubHttpClient::new()); http.push_response( 200, @@ -6080,12 +6097,12 @@ mod tests { http.push_response( 200, serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "aps-instance", "bid": [{ - "id": "mediated-aps", "impid": "fictional-slot", "price": 2.0, + "seatbid": [{"seat": "aps_instance", "bid": [{ + "id": "adserver-aps", "impid": "fictional-slot", "price": 2.0, "adm": "ignored", "w": 300, "h": 250, "crid": "aps-creative" }]}] })) - .expect("should serialize mediator response"), + .expect("should serialize adserver response"), ); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); let services = build_services_with_backend_and_http_client( @@ -6094,13 +6111,15 @@ mod tests { ); let plan = AuctionPlan::compile(planned_aps_config()).expect("should compile planned APS auction"); - let mediator = AdServerMockProvider::new(AdServerMockConfig { - enabled: true, - endpoint: "https://mediator.example/mediate".to_string(), - timeout_ms: 500, - ..AdServerMockConfig::default() - }); - let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(mediator))); + let adserver = AdServerMockProvider::new( + "adserver_mock", + AdServerMockSettings { + endpoint: "https://adserver.example/mediate".to_string(), + timeout_ms: 500, + ..AdServerMockSettings::default() + }, + ); + let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(adserver))); let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::new(edgezero_core::body::Body::empty()); @@ -6116,10 +6135,10 @@ mod tests { let result = orchestrator .run_auction(&request, &context) .await - .expect("should mediate planned APS bid"); + .expect("should decide planned APS bid"); let provider_bid = &result.provider_responses[0].bids[0]; - assert_eq!(result.provider_responses[0].provider, "aps-instance"); + assert_eq!(result.provider_responses[0].provider, "aps_instance"); assert_eq!(provider_bid.returned_seat.as_deref(), Some("upstream-seat")); assert_eq!(provider_bid.bidder, "aps"); let winner = &result.winning_bids["fictional-slot"]; @@ -6129,7 +6148,7 @@ mod tests { assert!(winner.creative.is_none()); assert_eq!( result - .mediator_response + .adserver_response .as_ref() .map(|response| response.provider.as_str()), Some("adserver_mock") @@ -6228,7 +6247,7 @@ mod tests { Arc::clone(&http) as Arc<_>, ); let plan = AuctionPlan::compile(planned_aps_instances_config(&[( - "aps-instance", + "aps_instance", serde_json::json!({"account_id": "example-account", "debug": true}), NotificationConfig { suppress_all: false, @@ -6255,7 +6274,7 @@ mod tests { .expect("should execute planned APS profile"); let response = &result.provider_responses[0]; - assert_eq!(response.provider, "aps-instance"); + assert_eq!(response.provider, "aps_instance"); assert_eq!(response.status, BidStatus::Success); assert_eq!( response.bids.len(), @@ -6278,7 +6297,7 @@ mod tests { let renderer = bid .renderer .as_ref() - .and_then(BidRenderer::as_aps) + .and_then(|renderer| renderer.payload_as::(APS_RENDERER_TYPE)) .expect("should construct typed APS renderer"); assert_eq!(renderer.account_id, "example-account"); let decoded = base64::engine::general_purpose::STANDARD @@ -6353,12 +6372,12 @@ mod tests { ); let plan = AuctionPlan::compile(planned_aps_instances_config(&[ ( - "aps-a", + "aps_a", serde_json::json!({"account_id":"account-a"}), NotificationConfig::default(), ), ( - "aps-b", + "aps_b", serde_json::json!({"account_id":"account-b"}), NotificationConfig::default(), ), @@ -6383,12 +6402,12 @@ mod tests { .expect("should execute two APS instances"); assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "aps-a"); + assert_eq!(result.provider_responses[0].provider, "aps_a"); assert_eq!( result.provider_responses[0].bids[0].bid_id.as_deref(), Some("bid-a") ); - assert_eq!(result.provider_responses[1].provider, "aps-b"); + assert_eq!(result.provider_responses[1].provider, "aps_b"); assert_eq!( result.provider_responses[1].bids[0].bid_id.as_deref(), Some("bid-b") @@ -6586,7 +6605,7 @@ mod tests { #[tokio::test] async fn planned_aps_script_opt_in_matches_shared_renderer_fixture() { let plan = AuctionPlan::compile(planned_aps_instances_config(&[( - "aps-instance", + "aps_instance", serde_json::json!({ "account_id":"example-account-id", "allow_script_creatives":true @@ -6633,7 +6652,7 @@ mod tests { let renderer = parsed.bids[0] .renderer .as_ref() - .and_then(BidRenderer::as_aps) + .and_then(|renderer| renderer.payload_as::(APS_RENDERER_TYPE)) .expect("should construct APS renderer"); let decoded = base64::engine::general_purpose::STANDARD .decode(&renderer.aax_response) @@ -6652,7 +6671,7 @@ mod tests { #[tokio::test] async fn planned_aps_debug_response_headers_are_allowlisted() { let plan = AuctionPlan::compile(planned_aps_instances_config(&[( - "aps-instance", + "aps_instance", serde_json::json!({"account_id":"example-account","debug":true}), NotificationConfig::default(), )])) @@ -6717,8 +6736,8 @@ mod tests { ); let plan = AuctionPlan::compile(planned_config( &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), ], false, )) @@ -6745,9 +6764,9 @@ mod tests { .expect("should execute planned auction"); assert_eq!(orchestrator.provider_count(), 2); - assert!(orchestrator.mediator().is_none()); + assert!(orchestrator.adserver().is_none()); assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!(result.provider_responses[0].provider, "provider_a"); assert_eq!( result.provider_responses[0].bids[0].bid_id.as_deref(), Some("bid-a") @@ -6762,7 +6781,7 @@ mod tests { result.provider_responses[0].metadata["routing"]["unused_bidder_params_count"], 0 ); - assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!(result.provider_responses[1].provider, "provider_b"); assert_eq!( result.provider_responses[1].bids[0].bid_id.as_deref(), Some("bid-b") @@ -6833,8 +6852,8 @@ mod tests { ); let plan = AuctionPlan::compile(planned_config( &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), ], false, )) @@ -6858,12 +6877,12 @@ mod tests { .expect("should isolate backend collision"); assert_eq!(http.recorded_backend_names().len(), 1); - assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!(result.provider_responses[0].provider, "provider_a"); assert_eq!( result.provider_responses[0].bids[0].bid_id.as_deref(), Some("first-bid") ); - assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!(result.provider_responses[1].provider, "provider_b"); assert_eq!( result.provider_responses[1].metadata["error_type"], "launch_failed" @@ -6883,8 +6902,8 @@ mod tests { ); let plan = AuctionPlan::compile(planned_config( &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), ], false, )) @@ -6908,12 +6927,12 @@ mod tests { .expect("should isolate divergent pending backend"); assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!(result.provider_responses[0].provider, "provider_a"); assert_eq!( result.provider_responses[0].metadata["error_type"], "launch_failed" ); - assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!(result.provider_responses[1].provider, "provider_b"); assert_eq!(result.provider_responses[1].status, BidStatus::NoBid); } @@ -6930,8 +6949,8 @@ mod tests { ); let plan = AuctionPlan::compile(planned_config( &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), ], false, )) @@ -6955,12 +6974,12 @@ mod tests { .expect("should isolate missing pending backend"); assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "provider-a"); + assert_eq!(result.provider_responses[0].provider, "provider_a"); assert_eq!( result.provider_responses[0].metadata["error_type"], "launch_failed" ); - assert_eq!(result.provider_responses[1].provider, "provider-b"); + assert_eq!(result.provider_responses[1].provider, "provider_b"); assert_eq!(result.provider_responses[1].status, BidStatus::NoBid); } @@ -6968,8 +6987,8 @@ mod tests { async fn planned_same_profile_rejects_cross_provider_parse_state() { let plan = AuctionPlan::compile(planned_config( &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), ], false, )) @@ -6996,7 +7015,7 @@ mod tests { .expect_err("should reject another provider's parse state"); assert!( - error.to_string().contains("owned by provider provider-a"), + error.to_string().contains("owned by provider provider_a"), "should identify cross-provider state ownership" ); } @@ -7005,12 +7024,12 @@ mod tests { async fn planned_prebid_rejects_cross_provider_parse_state() { let plan = AuctionPlan::compile(planned_prebid_config(&[ ( - "pbs-a", + "pbs_a", serde_json::json!({}), NotificationConfig::default(), ), ( - "pbs-b", + "pbs_b", serde_json::json!({}), NotificationConfig::default(), ), @@ -7038,7 +7057,7 @@ mod tests { .expect_err("should reject another PBS provider's parse state"); assert!( - error.to_string().contains("owned by provider pbs-a"), + error.to_string().contains("owned by provider pbs_a"), "should identify cross-provider PBS state ownership" ); } @@ -7106,18 +7125,27 @@ mod tests { Arc::clone(&http) as Arc<_>, ); let mut config = planned_config(&[("provider", RoutingMode::AllEligible)], false); - config.mediator = Some("adserver_mock".to_string()); + config.adserver = crate::provider_table::ProviderChoice::new( + Some("adserver_mock".to_string()), + BTreeMap::from([( + "adserver_mock".to_string(), + serde_json::Map::from_iter([( + "endpoint".to_string(), + serde_json::json!("https://adserver.example/mediate"), + )]), + )]), + ); let plan = AuctionPlan::compile(config).expect("should compile planned auction"); - let mediator_predicted = Arc::new(Mutex::new(Vec::new())); - let mediator_requested = Arc::new(Mutex::new(Vec::new())); - let mediator = Arc::new(recording_provider( + let adserver_predicted = Arc::new(Mutex::new(Vec::new())); + let adserver_requested = Arc::new(Mutex::new(Vec::new())); + let adserver = Arc::new(recording_provider( "adserver_mock", - "mediator-backend", + "adserver-backend", 777, - &mediator_predicted, - &mediator_requested, + &adserver_predicted, + &adserver_requested, )); - let orchestrator = AuctionOrchestrator::from_plan(Arc::new(plan), Some(mediator)); + let orchestrator = AuctionOrchestrator::from_plan(Arc::new(plan), Some(adserver)); let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::new(edgezero_core::body::Body::empty()); @@ -7155,15 +7183,15 @@ mod tests { assert_eq!(backend.ensured.load(Ordering::Relaxed), 0); assert!(http.recorded_backend_names().is_empty()); assert!( - mediator_predicted + adserver_predicted .lock() - .expect("should lock mediator predictions") + .expect("should lock adserver predictions") .is_empty() ); assert!( - mediator_requested + adserver_requested .lock() - .expect("should lock mediator requests") + .expect("should lock adserver requests") .is_empty() ); } @@ -7172,12 +7200,14 @@ mod tests { #[tokio::test] async fn planned_launch_transport_parse_failures_are_isolated_from_valid_winner_and_floor() { let http = Arc::new(StubHttpClient::new()); - // BTreeMap plan order is alphabetical: below-floor, parse-fail, - // transport-fail, valid-winner. Queue responses in that exact order. + // The plan runs its sources in the order the deployment selected them, + // so queue the responses in that same order: below_floor, parse_fail, + // transport_fail, valid_winner, with launch_fail never reaching + // transport. http.push_response( 200, serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "below-floor", "bid": [{ + "seatbid": [{"seat": "below_floor", "bid": [{ "id": "below", "impid": "fictional-slot", "price": 0.5, "adm": "
below
", "w": 300, "h": 250 }, { @@ -7203,18 +7233,18 @@ mod tests { http.push_select_success(); http.push_select_error(); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); - backend.fail_ensure_for("launch-fail"); + backend.fail_ensure_for("launch_fail"); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, ); let plan = AuctionPlan::compile(planned_config( &[ - ("launch-fail", RoutingMode::AllEligible), - ("transport-fail", RoutingMode::AllEligible), - ("parse-fail", RoutingMode::AllEligible), - ("below-floor", RoutingMode::AllEligible), - ("valid-winner", RoutingMode::AllEligible), + ("below_floor", RoutingMode::AllEligible), + ("launch_fail", RoutingMode::AllEligible), + ("parse_fail", RoutingMode::AllEligible), + ("transport_fail", RoutingMode::AllEligible), + ("valid_winner", RoutingMode::AllEligible), ], false, )) @@ -7247,7 +7277,7 @@ mod tests { .collect::>(); assert_eq!( by_provider - .get("launch-fail") + .get("launch_fail") .unwrap_or_else(|| panic!( "should include launch-fail response; got {:?}", by_provider.keys().collect::>() @@ -7255,7 +7285,7 @@ mod tests { .metadata["error_type"], "launch_failed" ); - let below_floor = by_provider.get("below-floor").unwrap_or_else(|| { + let below_floor = by_provider.get("below_floor").unwrap_or_else(|| { panic!( "should include below-floor response; got {:?}", by_provider.keys().collect::>() @@ -7266,10 +7296,10 @@ mod tests { assert_eq!(below_floor.bids[0].price, Some(0.5)); assert_eq!(below_floor.bids[1].bid_id.as_deref(), Some("below-only")); assert_eq!( - by_provider["transport-fail"].metadata["error_type"], + by_provider["transport_fail"].metadata["error_type"], "transport" ); - let parse_failure = by_provider.get("parse-fail").unwrap_or_else(|| { + let parse_failure = by_provider.get("parse_fail").unwrap_or_else(|| { panic!( "should include parse-fail response; got {:?}", by_provider.keys().collect::>() @@ -7287,7 +7317,7 @@ mod tests { "every materialized planned provider response should have routing count" ); } - assert_eq!(by_provider["valid-winner"].status, BidStatus::Success); + assert_eq!(by_provider["valid_winner"].status, BidStatus::Success); assert_eq!( result.winning_bids["fictional-slot"].bid_id.as_deref(), Some("winner") @@ -7300,7 +7330,7 @@ mod tests { #[tokio::test] async fn planned_routing_count_survives_standard_and_aps_bounded_body_failures() { - for (profile, provider_id) in [("standard", "standard"), ("aps", "aps-instance")] { + for (profile, provider_id) in [("standard", "standard"), ("aps", "aps_instance")] { let http = Arc::new(StubHttpClient::new()); http.push_response(200, vec![b'x'; 1024 * 1024 + 1]); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); @@ -7452,8 +7482,8 @@ mod tests { .build(); let plan = AuctionPlan::compile(planned_config( &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), ], true, )) @@ -7660,11 +7690,11 @@ mod tests { )) .expect("should compile signed plan"), ); - let mediator_launches = Arc::new(AtomicUsize::new(0)); + let adserver_launches = Arc::new(AtomicUsize::new(0)); let orchestrator = AuctionOrchestrator::from_plan( plan, - Some(Arc::new(DeadlineRecordingMediator { - launches: Arc::clone(&mediator_launches), + Some(Arc::new(DeadlineRecordingAdServer { + launches: Arc::clone(&adserver_launches), budgets: None, })), ); @@ -7700,9 +7730,9 @@ mod tests { assert_eq!(backend.ensured.load(Ordering::Relaxed), 0); assert!(http.recorded_backend_names().is_empty()); assert_eq!( - mediator_launches.load(Ordering::Relaxed), + adserver_launches.load(Ordering::Relaxed), 0, - "zero budget must not invoke even an immediate mediator" + "zero budget must not invoke even an immediate adserver" ); } @@ -7717,8 +7747,8 @@ mod tests { ); let plan = AuctionPlan::compile(planned_config( &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), ], false, )) diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs index df36fc09b..87a06ffb4 100644 --- a/crates/trusted-server-core/src/auction/plan.rs +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -1,37 +1,57 @@ -//! Target-independent config-first auction plan compiler. +//! Target-independent auction plan compiler. +//! +//! The plan is compiled once at startup from `[demand]`, `[adserver]` and +//! `[auction.bidders]`. Every selected name resolves to an implementation an +//! integration registered, so the compiler names no vendor, and request +//! handling reads only the compiled plan. use std::collections::{BTreeMap, BTreeSet}; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use error_stack::{Report, ResultExt as _}; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use url::Url; +use serde_json::{Map, Value}; +use url::{Host, Url}; -use super::profile::{CompiledOpenRtbProfile, ProfileTimeoutDefault, find_profile}; +use super::demand::{ + AdServerImplementation, CompiledDemand, DemandImplementation, DemandTimeoutDefault, +}; use crate::error::TrustedServerError; use crate::platform::{AuctionTargetId, PlatformBackendSpec}; +use crate::provider_table::{ProviderChoice, ProviderList}; use crate::settings::RequestSigning; const MAX_ID_BYTES: usize = 128; const MAX_SUPPRESS_SEATS: usize = 128; const MAX_SUPPRESS_SEAT_BYTES: usize = 128; -const MOCK_MEDIATOR_ID: &str = "adserver_mock"; const RESERVED_BROWSER_ENVELOPE_BIDDER_ID: &str = "trustedServer"; -/// Validated operator-defined provider identifier. +/// The keys of a `[demand.]` table that the common driver reads itself, +/// so an implementation receives the rest. +const ENDPOINT_KEY: &str = "endpoint"; +const TIMEOUT_KEY: &str = "timeout_ms"; +const ROUTING_KEY: &str = "routing"; +const NOTIFICATIONS_KEY: &str = "notifications"; + +/// A validated provider name, as written in `[demand] provider` or +/// `[adserver] provider`. #[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd, derive_more::Display)] pub struct ProviderId(String); impl ProviderId { - /// Borrow the validated identifier. + /// Borrow the validated name. #[must_use] pub fn as_str(&self) -> &str { &self.0 } #[cfg(test)] + #[allow( + dead_code, + reason = "kept for a legacy test path the provider types will retire" + )] pub(crate) fn unchecked_for_legacy_test(value: &str) -> Self { Self(value.to_string()) } @@ -47,10 +67,10 @@ impl FromStr for ProviderId { && value .as_bytes() .iter() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-'); + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'); if !valid { return Err(configuration_error(format!( - "provider ID `{value}` must match ^[a-z][a-z0-9-]{{0,62}}$" + "provider name `{value}` must be snake_case, matching ^[a-z][a-z0-9_]{{0,62}}$" ))); } Ok(Self(value.to_string())) @@ -124,80 +144,55 @@ impl Serialize for BidderId { } } -/// Raw config-first provider declaration. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct ProviderConfig { - /// Protocol identifier. Version one accepts only `openrtb-2.6`. - pub protocol: String, - /// Registered profile identifier. - #[serde(default = "default_profile")] - pub profile: String, - /// Fixed provider endpoint. - pub endpoint: String, - /// Optional profile-default timeout override. - #[serde(default)] - pub timeout_ms: Option, - /// Slot routing mode. - #[serde(default)] - pub routing: RoutingMode, - /// Common `OpenRTB` notification policy. - #[serde(default)] - pub notifications: NotificationConfig, - /// Selected profile's typed configuration object. - #[serde(default = "empty_object")] - pub profile_config: Value, -} - -/// Raw central bidder route. +/// A bidder code's route to one selected demand source. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct BidderRouteConfig { - /// Referenced provider identifier. + /// The `[demand]` provider the bidder is sent to. pub provider: ProviderId, } -/// Provider slot routing behavior. +/// Which slots a demand source receives. #[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RoutingMode { - /// Route only centrally assigned or trusted demand. + /// Only slots carrying a bidder routed to the source, or demand the server + /// routed to it. #[default] Explicit, - /// Route every banner-compatible slot. + /// Every banner-compatible slot. AllEligible, } -/// Common normalized-notification suppression configuration. +/// Notification URL suppression for one demand source. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct NotificationConfig { - /// Suppress notification URLs for every normalized bid. + /// Suppress notification URLs for every bid. #[serde(default)] pub suppress_all: bool, - /// Suppress notification URLs for exact returned-seat matches. + /// Suppress notification URLs for bids from these returned seats. #[serde(default)] pub suppress_seats: Vec, } -/// Raw internal input for target-independent plan compilation. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +/// Input for target-independent plan compilation. +#[derive(Debug, Clone, Default)] pub struct AuctionPlanConfig { /// Auction-wide logical timeout. pub timeout_ms: u32, - /// Operator-defined provider instances. - #[serde(default)] - pub providers: BTreeMap, - /// Client bidder-to-provider routes. - #[serde(default)] + /// The `[demand]` table. + pub demand: ProviderList, + /// The `[adserver]` table. + pub adserver: ProviderChoice, + /// Client bidder routes from `[auction.bidders]`. pub bidders: BTreeMap, - /// Existing separately registered mock mediator. - #[serde(default)] - pub mediator: Option, - /// Existing global Trusted Server signing configuration. - #[serde(default)] + /// Trusted Server's request signing configuration. pub request_signing: Option, + /// The demand implementations the integration builders registered. + pub demand_implementations: Vec<&'static DemandImplementation>, + /// The ad server implementations the integration builders registered. + pub adserver_implementations: Vec<&'static AdServerImplementation>, } /// Canonical absolute provider endpoint. @@ -216,13 +211,6 @@ impl CanonicalProviderEndpoint { } } -/// Closed first-version protocol plan. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum ProtocolPlan { - /// `OpenRTB` version 2.6 subset. - OpenRtb26, -} - /// Immutable common notification policy. #[derive(Debug, Clone, Default, Eq, PartialEq)] pub struct NotificationPolicy { @@ -232,23 +220,58 @@ pub struct NotificationPolicy { pub suppress_seats: BTreeSet, } -/// Immutable compiled provider instance. -#[derive(Debug, Clone)] +/// One compiled demand source. +#[derive(Clone)] pub struct ProviderPlan { - /// Provider identity. + /// The configured name. pub id: ProviderId, + /// The implementation the name resolved to. + pub implementation: &'static DemandImplementation, /// Canonical endpoint. pub endpoint: CanonicalProviderEndpoint, - /// Resolved profile-default or explicit timeout. + /// The table's timeout, or the implementation's default. pub timeout_ms: u32, /// Slot routing mode. pub routing: RoutingMode, /// Common notification policy. pub notifications: NotificationPolicy, - /// Compiled protocol behavior. - pub protocol: ProtocolPlan, - /// Compiled typed profile behavior. - pub profile: CompiledOpenRtbProfile, + /// The compiled request and response behavior. + pub demand: Arc, +} + +impl core::fmt::Debug for ProviderPlan { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("ProviderPlan") + .field("id", &self.id) + .field("implementation", &self.implementation.id) + .field("endpoint", &self.endpoint) + .field("timeout_ms", &self.timeout_ms) + .field("routing", &self.routing) + .field("notifications", &self.notifications) + .finish_non_exhaustive() + } +} + +/// The compiled ad server selection. +#[derive(Clone)] +pub struct AdServerPlan { + /// The configured name. + pub id: ProviderId, + /// The implementation the name resolved to. + pub implementation: &'static AdServerImplementation, + /// The table's own settings, without `implementation`. + pub settings: Map, +} + +impl core::fmt::Debug for AdServerPlan { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("AdServerPlan") + .field("id", &self.id) + .field("implementation", &self.implementation.id) + .finish_non_exhaustive() + } } /// Immutable target-independent auction plan. @@ -259,7 +282,7 @@ pub struct AuctionPlan { providers: Vec, bidder_routes: BTreeMap, signing_enabled: bool, - mediator: Option, + adserver: Option, } impl AuctionPlan { @@ -278,68 +301,98 @@ impl AuctionPlan { /// /// # Errors /// - /// Returns a configuration error for invalid identifiers, protocol/profile - /// declarations, endpoints, profile configuration, routes, notifications, - /// signing structure, or mediator selection. + /// Returns a configuration error for a table no selector names, a name + /// that is not `snake_case`, an implementation no builder registered, an + /// invalid endpoint, timeout, route or notification setting, settings an + /// implementation rejects, or invalid signing structure. pub fn compile(config: AuctionPlanConfig) -> Result> { if config.timeout_ms == 0 { return Err(configuration_error( "auction timeout_ms must be greater than zero", )); } - validate_mediator(config.mediator.as_deref())?; + config + .demand + .validate("demand") + .map_err(configuration_error)?; + config + .adserver + .validate("adserver") + .map_err(configuration_error)?; let signing_enabled = compile_signing_enabled(config.request_signing.as_ref())?; - let mut providers = Vec::with_capacity(config.providers.len()); + + let mut providers = Vec::new(); let mut provider_indices = BTreeMap::new(); - for (id, raw) in config.providers { - if raw.protocol != "openrtb-2.6" { + for name in config.demand.selected() { + let id = ProviderId::from_str(name)?; + let implementation_id = config.demand.implementation_of(name); + let implementation = config + .demand_implementations + .iter() + .copied() + .find(|implementation| implementation.id == implementation_id) + .ok_or_else(|| { + unknown_implementation("demand", name, implementation_id, { + config + .demand_implementations + .iter() + .map(|implementation| implementation.id) + }) + })?; + let mut settings = config.demand.settings_of(name); + let endpoint = take_setting::(&mut settings, "demand", name, ENDPOINT_KEY)? + .ok_or_else(|| configuration_error(format!("[demand.{name}] needs an endpoint")))?; + let timeout_ms = take_setting::(&mut settings, "demand", name, TIMEOUT_KEY)?; + let routing = take_setting::(&mut settings, "demand", name, ROUTING_KEY)? + .unwrap_or_default(); + let notifications = take_setting::( + &mut settings, + "demand", + name, + NOTIFICATIONS_KEY, + )? + .unwrap_or_default(); + + if routing == RoutingMode::AllEligible && !implementation.allows_all_eligible { return Err(configuration_error(format!( - "provider `{id}` uses unsupported protocol `{}`", - raw.protocol + "[demand.{name}] cannot use routing `all_eligible` with implementation `{}`; configure explicit bidder routes", + implementation.id ))); } - let registration = find_profile(&raw.profile).ok_or_else(|| { - configuration_error(format!( - "provider `{id}` uses unknown OpenRTB profile `{}`", - raw.profile - )) + let mut endpoint_url = check_endpoint("demand", name, &endpoint)?; + (implementation.canonicalize_endpoint)(&mut endpoint_url).map_err(|reason| { + configuration_error(format!("[demand.{name}] endpoint {reason}")) })?; - if registration.id == "prebid-server" && raw.routing == RoutingMode::AllEligible { - return Err(configuration_error(format!( - "provider `{id}` cannot use routing `all_eligible` with profile `prebid-server`; configure explicit bidder routes" - ))); - } - if !raw.profile_config.is_object() { - return Err(configuration_error(format!( - "provider `{id}` profile_config must be an object" - ))); - } - let endpoint = canonicalize_endpoint(&id, registration.id, &raw.endpoint)?; - let timeout_ms = raw - .timeout_ms - .unwrap_or(match registration.default_timeout { - ProfileTimeoutDefault::Auction => config.timeout_ms, - ProfileTimeoutDefault::Fixed(value) => value, - }); + let timeout_ms = timeout_ms.unwrap_or(match implementation.default_timeout { + DemandTimeoutDefault::Auction => config.timeout_ms, + DemandTimeoutDefault::Fixed(value) => value, + }); if timeout_ms == 0 { return Err(configuration_error(format!( - "provider `{id}` timeout_ms must be greater than zero" + "[demand.{name}] timeout_ms must be greater than zero" ))); } - let notifications = compile_notifications(&id, raw.notifications)?; - let profile = registration.compile(&raw.profile_config)?; - let index = providers.len(); - provider_indices.insert(id.clone(), index); + let notifications = compile_notifications(&id, notifications)?; + let demand = (implementation.compile)(&settings).change_context( + TrustedServerError::Configuration { + message: format!( + "[demand.{name}] settings are not valid for implementation `{}`", + implementation.id + ), + }, + )?; + provider_indices.insert(id.clone(), providers.len()); providers.push(ProviderPlan { id, - endpoint, + implementation, + endpoint: CanonicalProviderEndpoint(endpoint_url), timeout_ms, - routing: raw.routing, + routing, notifications, - protocol: ProtocolPlan::OpenRtb26, - profile, + demand, }); } + let mut bidder_routes = BTreeMap::new(); for (bidder, route) in config.bidders { if bidder.as_str() == RESERVED_BROWSER_ENVELOPE_BIDDER_ID { @@ -353,19 +406,61 @@ impl AuctionPlan { .copied() .ok_or_else(|| { configuration_error(format!( - "bidder `{bidder}` references unknown provider `{}`", + "[auction.bidders.{bidder}] sends the bidder to `{}`, which [demand] provider does not select", route.provider )) })?; bidder_routes.insert(bidder, provider_index); } + + let adserver = match config.adserver.selected().first().copied() { + None => None, + Some(name) => { + let id = ProviderId::from_str(name)?; + let implementation_id = config.adserver.implementation_of(name); + let implementation = config + .adserver_implementations + .iter() + .copied() + .find(|implementation| implementation.id == implementation_id) + .ok_or_else(|| { + unknown_implementation("adserver", name, implementation_id, { + config + .adserver_implementations + .iter() + .map(|implementation| implementation.id) + }) + })?; + let settings = config.adserver.settings_of(name); + if let Some(endpoint) = settings.get(ENDPOINT_KEY) { + let endpoint = endpoint.as_str().ok_or_else(|| { + configuration_error(format!("[adserver.{name}] endpoint must be a URL")) + })?; + check_endpoint("adserver", name, endpoint)?; + } + (implementation.build)(name, &settings).change_context( + TrustedServerError::Configuration { + message: format!( + "[adserver.{name}] settings are not valid for implementation `{}`", + implementation.id + ), + }, + )?; + Some(AdServerPlan { + id, + implementation, + settings, + }) + } + }; + Ok(Self { enabled: true, timeout_ms: config.timeout_ms, providers, bidder_routes, signing_enabled, - mediator: config.mediator, + adserver, }) } } @@ -470,38 +565,39 @@ impl AuctionPlan { Ok(()) } - /// Borrow compiled providers in deterministic provider-ID order. + /// Borrow compiled demand sources in the order `[demand] provider` lists + /// them. #[must_use] pub fn providers(&self) -> &[ProviderPlan] { &self.providers } - /// Borrow a compiled provider by its validated identity. + /// Borrow a compiled demand source by its name. #[must_use] pub(crate) fn provider(&self, id: &ProviderId) -> Option<&ProviderPlan> { self.providers.iter().find(|provider| provider.id == *id) } - /// Return whether any compiled provider uses the named profile. + /// Return whether any compiled demand source uses the named implementation. /// - /// This narrow query allows capability activation to follow the validated - /// plan without exposing profile configuration. + /// This narrow query lets an implementation's integration activate its own + /// capabilities from the validated plan. #[must_use] - pub fn has_profile(&self, profile_id: &str) -> bool { + pub fn has_implementation(&self, implementation_id: &str) -> bool { self.providers .iter() - .any(|provider| provider.profile.id() == profile_id) + .any(|provider| provider.implementation.id == implementation_id) } /// Borrow validated client-visible bidder route codes in deterministic order. /// /// This intentionally exposes route keys rather than provider identities or - /// profile configuration for the browser Prebid injection boundary. + /// implementation settings for the browser Prebid injection boundary. pub(crate) fn browser_bidder_codes(&self) -> impl Iterator { self.bidder_routes.keys().map(BidderId::as_str) } - /// Resolve a bidder route to a compiled provider. + /// Resolve a bidder route to a compiled demand source. #[must_use] pub fn provider_for_bidder(&self, bidder: &BidderId) -> Option<&ProviderPlan> { self.bidder_routes @@ -515,95 +611,100 @@ impl AuctionPlan { self.signing_enabled } - /// Borrow the separately validated static mediator identifier. + /// Borrow the selected ad server, when `[adserver] provider` names one. #[must_use] - pub fn mediator(&self) -> Option<&str> { - self.mediator.as_deref() + pub fn adserver(&self) -> Option<&AdServerPlan> { + self.adserver.as_ref() } } -fn default_profile() -> String { - "standard".to_string() -} - -fn empty_object() -> Value { - Value::Object(serde_json::Map::new()) -} - fn configuration_error(message: impl Into) -> Report { Report::new(TrustedServerError::Configuration { message: message.into(), }) } -fn compile_signing_enabled( - request_signing: Option<&RequestSigning>, -) -> Result> { - let Some(request_signing) = request_signing else { - return Ok(false); - }; - if request_signing.enabled - && (request_signing.config_store_id.trim().is_empty() - || request_signing.secret_store_id.trim().is_empty()) - { - return Err(configuration_error( - "enabled request_signing requires nonblank config_store_id and secret_store_id", - )); - } - Ok(request_signing.enabled) +fn unknown_implementation<'a>( + type_name: &str, + name: &str, + implementation_id: &str, + known: impl Iterator, +) -> Report { + let mut known = known.collect::>(); + known.sort_unstable(); + configuration_error(format!( + "[{type_name}] `{name}` uses implementation `{implementation_id}`, which this build does not have. The implementations it has are: {}", + known.join(", ") + )) } -fn validate_mediator(mediator: Option<&str>) -> Result<(), Report> { - if mediator.is_some_and(|value| value != MOCK_MEDIATOR_ID) { - return Err(configuration_error(format!( - "auction mediator must be `{MOCK_MEDIATOR_ID}` when configured" - ))); - } - Ok(()) +/// Takes one common setting out of a table's settings, parsing it as `T`. +fn take_setting( + settings: &mut Map, + type_name: &str, + name: &str, + key: &str, +) -> Result, Report> +where + T: for<'de> Deserialize<'de>, +{ + settings + .remove(key) + .map(|value| { + T::deserialize(value).map_err(|error| { + configuration_error(format!("[{type_name}.{name}] {key} is not valid: {error}")) + }) + }) + .transpose() } -fn canonicalize_endpoint( - provider_id: &ProviderId, - profile_id: &str, +/// Checks an endpoint the common way. It must be HTTPS, or plain HTTP to a +/// loopback address, with a host and no credentials or fragment. +fn check_endpoint( + type_name: &str, + name: &str, value: &str, -) -> Result> { - let mut endpoint = Url::parse(value).map_err(|error| { +) -> Result> { + let endpoint = Url::parse(value).map_err(|error| { configuration_error(format!( - "provider `{provider_id}` endpoint must be an absolute HTTPS URL: {error}" + "[{type_name}.{name}] endpoint must be an absolute URL: {error}" )) })?; - if endpoint.scheme() != "https" - || endpoint.host_str().is_none() + let loopback = match endpoint.host() { + Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + }; + let scheme_allowed = endpoint.scheme() == "https" || (endpoint.scheme() == "http" && loopback); + if !scheme_allowed + || endpoint.host().is_none() || !endpoint.username().is_empty() || endpoint.password().is_some() || endpoint.fragment().is_some() { return Err(configuration_error(format!( - "provider `{provider_id}` endpoint must be absolute HTTPS with a host and no credentials or fragment" - ))); - } - if profile_id == "aps" - && endpoint - .path() - .trim_end_matches('/') - .ends_with("/e/dtb/bid") - { - return Err(configuration_error(format!( - "provider `{provider_id}` uses unsupported legacy APS endpoint `/e/dtb/bid`" + "[{type_name}.{name}] endpoint must be HTTPS, or HTTP to 127.0.0.1, ::1 or localhost, with a host and no credentials or fragment" ))); } - if profile_id == "prebid-server" { - normalize_prebid_server_endpoint(&mut endpoint); - } - Ok(CanonicalProviderEndpoint(endpoint)) + Ok(endpoint) } -fn normalize_prebid_server_endpoint(endpoint: &mut Url) { - match endpoint.path() { - "" | "/" => endpoint.set_path("/openrtb2/auction"), - "/openrtb2/auction/" => endpoint.set_path("/openrtb2/auction"), - _ => {} +fn compile_signing_enabled( + request_signing: Option<&RequestSigning>, +) -> Result> { + let Some(request_signing) = request_signing else { + return Ok(false); + }; + if request_signing.enabled + && (request_signing.config_store_id.trim().is_empty() + || request_signing.secret_store_id.trim().is_empty()) + { + return Err(configuration_error( + "enabled request_signing requires nonblank config_store_id and secret_store_id", + )); } + Ok(request_signing.enabled) } fn compile_notifications( @@ -640,28 +741,73 @@ fn compile_notifications( #[cfg(test)] mod tests { use super::*; - use crate::auction::profile::CompiledOpenRtbProfile; - - fn provider(profile: &str) -> ProviderConfig { - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: profile.to_string(), - endpoint: "https://bid.example/openrtb2/auction".to_string(), - timeout_ms: None, - routing: RoutingMode::Explicit, - notifications: NotificationConfig::default(), - profile_config: empty_object(), + use crate::integrations::aps::ApsDemand; + use crate::integrations::openrtb::OpenRtbDemand; + use crate::integrations::prebid_server::PrebidServerDemand; + use crate::provider_table::IMPLEMENTATION_KEY; + use serde_json::json; + + /// The demand implementations the built-in builders register. + fn demand_implementations() -> Vec<&'static DemandImplementation> { + crate::integrations::all_builders(&[]) + .filter_map(|builder| builder.demand()) + .collect() + } + + /// The ad server implementations the built-in builders register. + fn adserver_implementations() -> Vec<&'static AdServerImplementation> { + crate::integrations::all_builders(&[]) + .filter_map(|builder| builder.adserver()) + .collect() + } + + /// One `[demand.]` table naming an implementation. + fn table(implementation: &str) -> Map { + let mut table = Map::from_iter([ + (IMPLEMENTATION_KEY.to_string(), json!(implementation)), + ( + ENDPOINT_KEY.to_string(), + json!("https://bid.example/openrtb2/auction"), + ), + ]); + if implementation == "aps" { + table.insert( + ENDPOINT_KEY.to_string(), + json!("https://aps.example/e/pb/bid"), + ); + table.insert("account_id".to_string(), json!("example-account")); } + table + } + + /// A `[demand]` table selecting every name given, in order. + fn demand(tables: Vec<(&str, Map)>) -> ProviderList { + let selected = tables + .iter() + .map(|(name, _)| (*name).to_string()) + .collect::>(); + let tables = tables + .into_iter() + .map(|(name, table)| (name.to_string(), table)) + .collect::>(); + ProviderList::new(selected, tables) } - fn config(providers: BTreeMap) -> AuctionPlanConfig { + fn config(tables: Vec<(&str, Map)>) -> AuctionPlanConfig { AuctionPlanConfig { timeout_ms: 1500, - providers, + demand: demand(tables), + demand_implementations: demand_implementations(), + adserver_implementations: adserver_implementations(), ..AuctionPlanConfig::default() } } + /// A plan with one ordinary `OpenRTB` source called `one`. + fn one_source() -> AuctionPlanConfig { + config(vec![("one", table("openrtb"))]) + } + fn id(value: &str) -> ProviderId { ProviderId::from_str(value).expect("should parse provider ID") } @@ -673,10 +819,7 @@ mod tests { fn nested_object(levels: usize) -> Value { let mut value = Value::String("leaf".to_string()); for level in 0..levels { - value = Value::Object(serde_json::Map::from_iter([( - format!("level-{level}"), - value, - )])); + value = Value::Object(Map::from_iter([(format!("level_{level}"), value)])); } value } @@ -691,11 +834,11 @@ mod tests { #[test] fn target_validation_accepts_fanout_and_rejects_unsupported_targets() { - let providers = BTreeMap::from([ - (id("provider-one"), provider("standard")), - (id("provider-two"), provider("standard")), - ]); - let plan = AuctionPlan::compile(config(providers)).expect("should compile plan"); + let plan = AuctionPlan::compile(config(vec![ + ("provider_one", table("openrtb")), + ("provider_two", table("openrtb")), + ])) + .expect("should compile plan"); assert!( plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) @@ -708,236 +851,128 @@ mod tests { "Axum should accept provider fanout" ); for target in [ - crate::platform::AuctionTargetId::Cloudflare, crate::platform::AuctionTargetId::Spin, + crate::platform::AuctionTargetId::Cloudflare, ] { - let error = plan - .validate_for_target(target) - .expect_err("should reject unsupported provider fanout"); assert!( - error.to_string().contains("fanout"), - "should explain fanout rejection: {error:?}" + plan.validate_for_target(target).is_err(), + "{target:?} runs one demand source at a time and should refuse fanout" ); } } #[test] - fn fastly_target_validation_canonicalizes_url_derived_ipv6_prediction() { - let mut ipv6_provider = provider("standard"); - ipv6_provider.endpoint = "https://[2001:db8::5]:8443/openrtb".to_string(); - ipv6_provider.timeout_ms = Some(750); - let plan = AuctionPlan::compile(config(BTreeMap::from([( - id("ipv6-provider"), - ipv6_provider, - )]))) - .expect("should compile IPv6 provider plan"); - - plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) - .expect("Fastly target validation should accept canonical IPv6 prediction"); - let bracketed_spec = plan.providers()[0].backend_spec(); - assert_eq!(bracketed_spec.host, "[2001:db8::5]"); - let mut bare_spec = bracketed_spec.clone(); - bare_spec.host = "2001:db8::5".to_string(); - let policy = crate::platform::BackendNamingPolicy::Fastly; - assert_eq!( - policy - .predict(&bracketed_spec) - .expect("should predict URL-derived bracketed IPv6 backend"), - policy - .predict(&bare_spec) - .expect("should predict runtime-normalized bare IPv6 backend"), - "startup target validation and Fastly runtime must hash the same backend spec" - ); - } - - #[test] - fn fastly_target_validation_reserves_dynamic_backends_outside_auction() { - let plan_with = |count: usize| { - let providers = (0..count) - .map(|index| { - let mut provider = provider("standard"); - provider.timeout_ms = Some(1000); - (id(&format!("provider-{index}")), provider) - }) - .collect(); - AuctionPlan::compile(config(providers)).expect("should compile provider plan") - }; - - plan_with(19) - .validate_for_target(crate::platform::AuctionTargetId::Fastly) - .expect("below-budget provider plan should validate"); - plan_with(20) - .validate_for_target(crate::platform::AuctionTargetId::Fastly) - .expect("at-budget provider plan should validate"); - let error = plan_with(21) - .validate_for_target(crate::platform::AuctionTargetId::Fastly) - .expect_err("over-budget provider plan should fail"); - assert!( - error - .to_string() - .contains("exceeding its auction budget of 160") + fn a_table_no_selector_names_is_refused() { + let mut raw = one_source(); + raw.demand = ProviderList::new( + vec!["one".to_string()], + BTreeMap::from([ + ("one".to_string(), table("openrtb")), + ("left_behind".to_string(), table("openrtb")), + ]), ); - } - - #[test] - fn fastly_backend_quota_uses_auction_timeout_as_reachable_bucket_ceiling() { - let providers = (0..21) - .map(|index| { - let mut provider = provider("standard"); - provider.timeout_ms = Some(1000); - (id(&format!("provider-{index}")), provider) - }) - .collect(); - let mut bounded = config(providers); - bounded.timeout_ms = 100; - let plan = AuctionPlan::compile(bounded).expect("should compile bounded provider plan"); - + let error = AuctionPlan::compile(raw).expect_err("should refuse a table nothing selects"); + let message = error.to_string(); assert!( - plan.providers() - .iter() - .all(|provider| provider.timeout_ms == 1000), - "quota validation must not rewrite configured provider timeouts" + message.contains("left_behind") && message.contains("provider"), + "should name the table and the selector: {error:?}" ); - plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) - .expect("21 providers reach only the 50ms and 100ms Fastly buckets"); } #[test] - fn disabled_target_validation_skips_fanout_and_collision_checks() { - let providers = BTreeMap::from([ - (id("provider-one"), provider("standard")), - (id("provider-two"), provider("standard")), - ]); - let disabled = AuctionPlan::compile(config(providers)) - .expect("should compile plan") - .with_enabled(false); - - for target in [ - crate::platform::AuctionTargetId::Cloudflare, - crate::platform::AuctionTargetId::Spin, - ] { - disabled - .validate_for_target(target) - .expect("disabled dormant providers should skip target validation"); + fn an_unknown_implementation_names_the_ones_this_build_has() { + let mut absent = table("openrtb"); + absent.insert(IMPLEMENTATION_KEY.to_string(), json!("fictional_exchange")); + let error = AuctionPlan::compile(config(vec![("one", absent)])) + .expect_err("should refuse an implementation this build does not have"); + let message = error.to_string(); + for expected in ["fictional_exchange", "openrtb", "prebid_server", "aps"] { + assert!( + message.contains(expected), + "should name the unknown implementation and the known ones: {error:?}" + ); } - - let provider = disabled.providers[0].clone(); - let disabled_collision = AuctionPlan { - enabled: false, - timeout_ms: disabled.timeout_ms, - providers: vec![provider.clone(), provider], - bidder_routes: BTreeMap::new(), - signing_enabled: false, - mediator: None, - }; - disabled_collision - .validate_for_target(crate::platform::AuctionTargetId::Axum) - .expect("disabled dormant providers should skip collision validation"); } #[test] - fn target_validation_keeps_same_origin_timeout_profile_instances_distinct() { - let shared = ProviderConfig { - timeout_ms: Some(777), - ..provider("standard") - }; - let plan = AuctionPlan::compile(config(BTreeMap::from([ - (id("provider-one"), shared.clone()), - (id("provider-two"), shared), - ]))) - .expect("should compile same-origin provider instances"); - - for target in [ - crate::platform::AuctionTargetId::Fastly, - crate::platform::AuctionTargetId::Axum, - ] { - plan.validate_for_target(target) - .expect("provider ID discriminators should prevent predicted collisions"); - } + fn a_name_that_is_its_own_implementation_needs_no_implementation_line() { + let mut named = Map::from_iter([( + ENDPOINT_KEY.to_string(), + json!("https://bid.example/openrtb2/auction"), + )]); + named.insert("request_ext".to_string(), json!({"fictional": "example"})); + let plan = AuctionPlan::compile(config(vec![("openrtb", named)])) + .expect("should take the name as the implementation"); + assert_eq!(plan.providers()[0].implementation.id, "openrtb"); } #[test] - fn target_validation_rejects_predicted_name_collisions() { - let compiled = AuctionPlan::compile(config(BTreeMap::from([( - id("provider-a"), - provider("standard"), - )]))) - .expect("should compile plan"); - let provider = compiled.providers[0].clone(); - // The compiler prevents duplicate provider IDs. Construct the otherwise - // impossible duplicate internally to pin validation's defense-in-depth - // collision rejection independently of compiler invariants. - let collision_plan = AuctionPlan { - enabled: true, - timeout_ms: compiled.timeout_ms, - providers: vec![provider.clone(), provider], - bidder_routes: BTreeMap::new(), - signing_enabled: false, - mediator: None, - }; - - let error = collision_plan - .validate_for_target(crate::platform::AuctionTargetId::Axum) - .expect_err("should reject predicted backend collision"); - assert!(error.to_string().contains("same backend name")); + fn a_demand_table_needs_an_endpoint() { + let mut without = table("openrtb"); + without.remove(ENDPOINT_KEY); + let error = AuctionPlan::compile(config(vec![("one", without)])) + .expect_err("should refuse a source with no endpoint"); + assert!( + error.to_string().contains("endpoint"), + "should say an endpoint is needed: {error:?}" + ); } #[test] fn provider_id_enforces_exact_grammar_and_bounds() { - for valid in ["a", "pbs-primary", &format!("a{}", "0".repeat(62))] { - assert!(ProviderId::from_str(valid).is_ok(), "should accept {valid}"); + for value in ["a", "provider_one", "p0", &format!("a{}", "b".repeat(62))] { + assert!( + ProviderId::from_str(value).is_ok(), + "should accept `{value}`" + ); } - for invalid in [ + for value in [ "", - "A", + "Provider", + "provider-one", "1provider", - "provider_name", - "provider.name", - "provider/one", - &format!("a{}", "0".repeat(63)), + "_provider", + "provider.one", + &format!("a{}", "b".repeat(63)), ] { assert!( - ProviderId::from_str(invalid).is_err(), - "should reject {invalid}" + ProviderId::from_str(value).is_err(), + "should reject `{value}`" ); } } #[test] fn bidder_id_enforces_admission_bounds() { - assert!(BidderId::from_str("exampleBidder").is_ok()); - for invalid in ["", " bidder", "bidder\n", &"a".repeat(129)] { - let error = BidderId::from_str(invalid).expect_err("should reject invalid bidder ID"); - assert!( - error.to_string().contains(&format!("{invalid:?}")), - "should identify invalid bidder ID {invalid:?}: {error:?}" - ); - } + assert!(BidderId::from_str("example").is_ok()); + assert!(BidderId::from_str("Example").is_ok()); + assert!(BidderId::from_str("").is_err()); + assert!(BidderId::from_str(&"a".repeat(MAX_ID_BYTES + 1)).is_err()); } #[test] - fn compiler_rejects_all_eligible_for_prebid_server_only() { - let mut prebid = provider("prebid-server"); - prebid.routing = RoutingMode::AllEligible; - let error = AuctionPlan::compile(config(BTreeMap::from([(id("pbs-main"), prebid)]))) - .expect_err("should reject all_eligible Prebid Server routing"); + fn all_eligible_is_refused_for_an_implementation_that_forbids_it() { + let mut prebid = table("prebid_server"); + prebid.insert(ROUTING_KEY.to_string(), json!("all_eligible")); + let error = AuctionPlan::compile(config(vec![("pbs_main", prebid)])) + .expect_err("should refuse all_eligible Prebid Server routing"); let message = error.to_string(); - for expected in ["pbs-main", "all_eligible", "prebid-server"] { + for expected in ["pbs_main", "all_eligible", "prebid_server"] { assert!( message.contains(expected), - "should identify provider, routing, and profile: {error:?}" + "should name the source, the routing and the implementation: {error:?}" ); } - let mut standard = provider("standard"); - standard.routing = RoutingMode::AllEligible; - AuctionPlan::compile(config(BTreeMap::from([(id("standard-main"), standard)]))) - .expect("should retain all_eligible for non-Prebid profiles"); + let mut openrtb = table("openrtb"); + openrtb.insert(ROUTING_KEY.to_string(), json!("all_eligible")); + AuctionPlan::compile(config(vec![("openrtb_main", openrtb)])) + .expect("should keep all_eligible for an implementation that allows it"); } #[test] fn compiler_rejects_exact_reserved_browser_envelope_bidder_id() { - let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + let mut raw = one_source(); raw.bidders.insert( bidder("trustedServer"), BidderRouteConfig { @@ -949,7 +984,7 @@ mod tests { "exact reserved bidder ID should be rejected" ); - let mut case_distinct = config(BTreeMap::from([(id("one"), provider("standard"))])); + let mut case_distinct = one_source(); case_distinct.bidders.insert( bidder("TrustedServer"), BidderRouteConfig { @@ -963,26 +998,30 @@ mod tests { } #[test] - fn compiler_orders_providers_and_routes_deterministically() { - let mut providers = BTreeMap::new(); - providers.insert(id("z-provider"), provider("standard")); - providers.insert(id("a-provider"), provider("standard")); - let mut raw = config(providers); + fn sources_keep_the_order_they_were_selected_in_and_routes_are_deterministic() { + let mut raw = config(vec![ + ("z_provider", table("openrtb")), + ("a_provider", table("openrtb")), + ]); raw.bidders.insert( bidder("z-bidder"), BidderRouteConfig { - provider: id("z-provider"), + provider: id("z_provider"), }, ); raw.bidders.insert( bidder("a-bidder"), BidderRouteConfig { - provider: id("a-provider"), + provider: id("a_provider"), }, ); let plan = AuctionPlan::compile(raw).expect("should compile deterministic plan"); - assert_eq!(plan.providers()[0].id.as_str(), "a-provider"); - assert_eq!(plan.providers()[1].id.as_str(), "z-provider"); + assert_eq!( + plan.providers()[0].id.as_str(), + "z_provider", + "the selector's order is the deployment's order" + ); + assert_eq!(plan.providers()[1].id.as_str(), "a_provider"); assert_eq!( plan.browser_bidder_codes().collect::>(), vec!["a-bidder", "z-bidder"], @@ -991,113 +1030,98 @@ mod tests { assert_eq!( plan.provider_for_bidder(&bidder("a-bidder")) .map(|provider| provider.id.as_str()), - Some("a-provider") + Some("a_provider") ); } #[test] - fn compiler_supports_two_instances_of_the_same_profile() { - let mut providers = BTreeMap::new(); - providers.insert(id("pbs-a"), provider("prebid-server")); - providers.insert(id("pbs-b"), provider("prebid-server")); - let plan = AuctionPlan::compile(config(providers)).expect("should compile two PBS plans"); + fn two_sources_can_run_one_implementation_under_their_own_names() { + let plan = AuctionPlan::compile(config(vec![ + ("pbs_a", table("prebid_server")), + ("pbs_b", table("prebid_server")), + ])) + .expect("should compile two Prebid Server sources"); assert_eq!(plan.providers().len(), 2); assert!( - plan.providers().iter().all(|provider| matches!( - provider.profile, - CompiledOpenRtbProfile::PrebidServer(_) - )) + plan.providers() + .iter() + .all(|provider| provider.implementation.id == "prebid_server"), + "both names should resolve to the same implementation" + ); + assert!( + plan.providers() + .iter() + .all(|provider| provider.demand.as_any().is::()), + "each source should compile its own settings" ); } #[test] - fn profile_defaults_and_explicit_timeout_override_are_resolved() { - let mut providers = BTreeMap::new(); - providers.insert(id("standard-one"), provider("standard")); - providers.insert(id("pbs-one"), provider("prebid-server")); - providers.insert( - id("aps-one"), - ProviderConfig { - endpoint: "https://aps.example/e/pb/bid".to_string(), - profile_config: serde_json::json!({"account_id": "example-account"}), - ..provider("aps") - }, - ); - providers.insert( - id("pbs-override"), - ProviderConfig { - timeout_ms: Some(321), - ..provider("prebid-server") - }, - ); - let plan = AuctionPlan::compile(config(providers)).expect("should resolve timeouts"); + fn implementation_defaults_and_an_explicit_timeout_are_resolved() { + let mut override_table = table("prebid_server"); + override_table.insert(TIMEOUT_KEY.to_string(), json!(321)); + let plan = AuctionPlan::compile(config(vec![ + ("openrtb_one", table("openrtb")), + ("pbs_one", table("prebid_server")), + ("aps_one", table("aps")), + ("pbs_override", override_table), + ])) + .expect("should resolve timeouts"); let timeouts = plan .providers() .iter() .map(|provider| (provider.id.as_str(), provider.timeout_ms)) .collect::>(); - assert_eq!(timeouts["standard-one"], 1500); - assert_eq!(timeouts["pbs-one"], 1000); - assert_eq!(timeouts["aps-one"], 800); - assert_eq!(timeouts["pbs-override"], 321); + assert_eq!(timeouts["openrtb_one"], 1500); + assert_eq!(timeouts["pbs_one"], 1000); + assert_eq!(timeouts["aps_one"], 800); + assert_eq!(timeouts["pbs_override"], 321); } #[test] - fn profile_registry_is_independent_of_browser_configuration() { - let ids = crate::auction::profile::profile_registrations() - .iter() - .map(|registration| registration.id) - .collect::>(); - assert_eq!(ids, vec!["standard", "prebid-server", "aps"]); - let mut providers = BTreeMap::new(); - providers.insert(id("pbs"), provider("prebid-server")); - let plan = AuctionPlan::compile(config(providers)) + fn the_plan_reports_which_implementations_it_selected() { + let plan = AuctionPlan::compile(config(vec![("pbs", table("prebid_server"))])) .expect("should compile without Settings or browser integration state"); - assert!(!plan.has_profile("aps")); - - let mut providers = BTreeMap::new(); - providers.insert( - id("aps-instance"), - ProviderConfig { - endpoint: "https://aps.example/e/pb/bid".to_string(), - profile_config: serde_json::json!({"account_id": "example-account"}), - ..provider("aps") - }, + assert!(!plan.has_implementation("aps")); + assert!(plan.has_implementation("prebid_server")); + + let plan = AuctionPlan::compile(config(vec![("aps_instance", table("aps"))])) + .expect("should compile APS plan"); + assert!( + plan.has_implementation("aps"), + "a validated plan should expose its APS renderer capability" ); - let plan = AuctionPlan::compile(config(providers)).expect("should compile APS plan"); + assert!(!plan.has_implementation("prebid_server")); assert!( - plan.has_profile("aps"), - "validated plan should expose APS renderer capability" + plan.providers()[0].demand.as_any().is::(), + "the APS source should compile its own settings" ); - assert!(!plan.has_profile("prebid-server")); } #[test] - fn compiler_rejects_unknown_protocol_profile_and_route() { - let mut unknown_protocol = provider("standard"); - unknown_protocol.protocol = "openrtb-2.5".to_string(); - assert!( - AuctionPlan::compile(config(BTreeMap::from([(id("one"), unknown_protocol)]))).is_err() - ); - assert!( - AuctionPlan::compile(config(BTreeMap::from([(id("one"), provider("unknown"))]))) - .is_err() - ); - let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + fn a_bidder_route_must_name_a_selected_source() { + let mut raw = one_source(); raw.bidders.insert( bidder("example"), BidderRouteConfig { provider: id("missing"), }, ); - assert!(AuctionPlan::compile(raw).is_err()); + let error = AuctionPlan::compile(raw).expect_err("should refuse an unrouted bidder"); + assert!( + error.to_string().contains("missing"), + "should name the source the route wanted: {error:?}" + ); } #[test] fn compiler_canonicalizes_https_endpoints_and_rejects_unsafe_forms() { - let mut canonical = provider("standard"); - canonical.endpoint = "https://BID.EXAMPLE:443/path".to_string(); - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), canonical)]))) + let mut canonical = table("openrtb"); + canonical.insert( + ENDPOINT_KEY.to_string(), + json!("https://BID.EXAMPLE:443/path"), + ); + let plan = AuctionPlan::compile(config(vec![("one", canonical)])) .expect("should canonicalize endpoint"); assert_eq!( plan.providers()[0].endpoint.as_str(), @@ -1110,21 +1134,55 @@ mod tests { "https://bid.example/path#fragment", "/relative", ] { - let mut raw_provider = provider("standard"); - raw_provider.endpoint = endpoint.to_string(); + let mut raw = table("openrtb"); + raw.insert(ENDPOINT_KEY.to_string(), json!(endpoint)); assert!( - AuctionPlan::compile(config(BTreeMap::from([(id("one"), raw_provider)]))).is_err(), + AuctionPlan::compile(config(vec![("one", raw)])).is_err(), "should reject {endpoint}" ); } - let mut aps = provider("aps"); - aps.endpoint = "https://aps.example/e/dtb/bid".to_string(); - aps.profile_config = serde_json::json!({"account_id": "example-account"}); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))).is_err()); + let mut aps = table("aps"); + aps.insert( + ENDPOINT_KEY.to_string(), + json!("https://aps.example/e/dtb/bid"), + ); + assert!( + AuctionPlan::compile(config(vec![("aps", aps)])).is_err(), + "should refuse the legacy APS path" + ); + } + + #[test] + fn plain_http_is_allowed_only_to_a_loopback_host() { + for endpoint in [ + "http://127.0.0.1:8000/openrtb2/auction", + "http://[::1]:8000/openrtb2/auction", + "http://localhost:8000/openrtb2/auction", + "http://LOCALHOST:8000/openrtb2/auction", + ] { + let mut raw = table("openrtb"); + raw.insert(ENDPOINT_KEY.to_string(), json!(endpoint)); + AuctionPlan::compile(config(vec![("local", raw)])) + .unwrap_or_else(|error| panic!("should accept {endpoint}: {error:?}")); + } + for endpoint in [ + "http://192.0.2.10:8000/openrtb2/auction", + "http://bid.example/openrtb2/auction", + "http://localhost.example/openrtb2/auction", + ] { + let mut raw = table("openrtb"); + raw.insert(ENDPOINT_KEY.to_string(), json!(endpoint)); + let error = AuctionPlan::compile(config(vec![("remote", raw)])) + .expect_err("should reject plain HTTP off the loopback"); + assert!( + error.to_string().contains("127.0.0.1"), + "should say which hosts plain HTTP may reach: {error:?}" + ); + } } #[test] - fn compiler_normalizes_only_prebid_server_origin_and_canonical_paths() { + fn only_prebid_server_completes_an_endpoint_that_names_a_host_alone() { for (configured, expected) in [ ( "https://pbs.example", @@ -1152,9 +1210,9 @@ mod tests { "https://pbs.example/custom/pbs", ), ] { - let mut pbs = provider("prebid-server"); - pbs.endpoint = configured.to_string(); - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("pbs"), pbs)]))) + let mut pbs = table("prebid_server"); + pbs.insert(ENDPOINT_KEY.to_string(), json!(configured)); + let plan = AuctionPlan::compile(config(vec![("pbs", pbs)])) .expect("should compile Prebid Server endpoint"); assert_eq!( plan.providers()[0].endpoint.as_str(), @@ -1163,19 +1221,16 @@ mod tests { ); } - let mut standard = provider("standard"); - standard.endpoint = "https://bid.example/".to_string(); - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("standard"), standard)]))) - .expect("should compile standard root endpoint"); + let mut openrtb = table("openrtb"); + openrtb.insert(ENDPOINT_KEY.to_string(), json!("https://bid.example/")); + let plan = AuctionPlan::compile(config(vec![("openrtb", openrtb)])) + .expect("should compile a root endpoint unchanged"); assert_eq!( plan.providers()[0].endpoint.as_str(), "https://bid.example/" ); - let mut aps = provider("aps"); - aps.endpoint = "https://aps.example/e/pb/bid".to_string(); - aps.profile_config = serde_json::json!({"account_id": "example-account"}); - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))) + let plan = AuctionPlan::compile(config(vec![("aps", table("aps"))])) .expect("should compile APS endpoint"); assert_eq!( plan.providers()[0].endpoint.as_str(), @@ -1184,103 +1239,80 @@ mod tests { } #[test] - fn standard_extensions_are_typed_bounded_and_cannot_claim_reserved_fields() { - let mut valid = provider("standard"); - valid.profile_config = serde_json::json!({ - "request_ext": {"fictional_account": "example"}, - "imp_ext": {"placement_group": "display"} - }); - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), valid)]))) + fn openrtb_extensions_are_bounded_and_cannot_claim_reserved_fields() { + let mut valid = table("openrtb"); + valid.insert( + "request_ext".to_string(), + json!({"fictional_account": "example"}), + ); + valid.insert("imp_ext".to_string(), json!({"placement_group": "display"})); + let plan = AuctionPlan::compile(config(vec![("one", valid)])) .expect("should compile static extensions"); - let CompiledOpenRtbProfile::Standard(standard) = &plan.providers()[0].profile else { - panic!("should compile standard profile") - }; + let openrtb = plan.providers()[0] + .demand + .as_any() + .downcast_ref::() + .expect("should compile the OpenRTB implementation"); assert_eq!( - standard.request_ext.as_object()["fictional_account"], + openrtb.request_ext.as_object()["fictional_account"], "example" ); - let mut standard_owned_fields = provider("standard"); - standard_owned_fields.profile_config = serde_json::json!({ - "request_ext": { - "account": "example-account", - "sdk": {"source": "example"}, - "prebid": {"example": true} - }, - "imp_ext": {"prebid": {"example": true}} - }); - AuctionPlan::compile(config(BTreeMap::from([( - id("standard-owned-fields"), - standard_owned_fields, - )]))) - .expect("should allow standard static extensions outside common-owned fields"); - - for profile_config in [ - serde_json::json!({"request_ext": "bad"}), - serde_json::json!({"request_ext": {"trusted_server": {}}}), - ] { - let mut invalid = provider("standard"); - invalid.profile_config = profile_config; - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); - } - let oversized = "x".repeat(16 * 1024); - let mut invalid = provider("standard"); - invalid.profile_config = serde_json::json!({"request_ext": {"value": oversized}}); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); - - let too_many_keys = (0..257) - .map(|index| (format!("key-{index}"), Value::Bool(true))) - .collect::>(); - let mut invalid = provider("standard"); - invalid.profile_config = serde_json::json!({"imp_ext": too_many_keys}); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); - } + let mut reserved = table("openrtb"); + reserved.insert( + "request_ext".to_string(), + json!({"trusted_server": {"signature": "forged"}}), + ); + assert!( + AuctionPlan::compile(config(vec![("one", reserved)])).is_err(), + "should refuse an extension claiming a reserved field" + ); - #[test] - fn standard_extension_depth_counts_container_levels() { - let mut object_valid = provider("standard"); - object_valid.profile_config = serde_json::json!({"request_ext": nested_object(8)}); - AuctionPlan::compile(config(BTreeMap::from([(id("object-valid"), object_valid)]))) - .expect("should accept eight nested object levels"); - - let mut object_invalid = provider("standard"); - object_invalid.profile_config = serde_json::json!({"request_ext": nested_object(9)}); + let mut too_large = table("openrtb"); + too_large.insert( + "request_ext".to_string(), + json!({"padding": "x".repeat(17 * 1024)}), + ); + assert!( + AuctionPlan::compile(config(vec![("one", too_large)])).is_err(), + "should refuse an extension over the size bound" + ); + + let mut too_deep = table("openrtb"); + too_deep.insert("request_ext".to_string(), nested_object(9)); assert!( - AuctionPlan::compile(config(BTreeMap::from([( - id("object-invalid"), - object_invalid, - )]))) - .is_err(), - "should reject nine nested object levels" + AuctionPlan::compile(config(vec![("one", too_deep)])).is_err(), + "should refuse an extension over the depth bound" ); - let mut array_valid = provider("standard"); - array_valid.profile_config = serde_json::json!({"request_ext": {"value": nested_array(7)}}); - AuctionPlan::compile(config(BTreeMap::from([(id("array-valid"), array_valid)]))) - .expect("should accept one object plus seven nested array levels"); + let mut deep_array = table("openrtb"); + deep_array.insert( + "request_ext".to_string(), + json!({"levels": nested_array(8)}), + ); + assert!( + AuctionPlan::compile(config(vec![("one", deep_array)])).is_err(), + "should count array levels toward the depth bound" + ); - let mut array_invalid = provider("standard"); - array_invalid.profile_config = - serde_json::json!({"request_ext": {"value": nested_array(8)}}); + let mut not_an_object = table("openrtb"); + not_an_object.insert("request_ext".to_string(), json!("string")); assert!( - AuctionPlan::compile(config(BTreeMap::from([( - id("array-invalid"), - array_invalid, - )]))) - .is_err(), - "should reject one object plus eight nested array levels" + AuctionPlan::compile(config(vec![("one", not_an_object)])).is_err(), + "should refuse an extension that is not an object" ); } #[test] fn notification_policy_rejects_duplicates_and_bounds() { - let mut valid = provider("standard"); - valid.notifications = NotificationConfig { - suppress_all: true, - suppress_seats: vec!["seat-b".to_string(), "seat-a".to_string()], - }; - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), valid)]))) + let mut valid = table("openrtb"); + valid.insert( + NOTIFICATIONS_KEY.to_string(), + json!({"suppress_all": true, "suppress_seats": ["seat-b", "seat-a"]}), + ); + let plan = AuctionPlan::compile(config(vec![("one", valid)])) .expect("should compile notifications"); + assert!(plan.providers()[0].notifications.suppress_all); assert_eq!( plan.providers()[0] .notifications @@ -1291,42 +1323,57 @@ mod tests { vec!["seat-a", "seat-b"] ); for seats in [ - vec!["same".to_string(), "same".to_string()], - vec![String::new()], - vec!["bad\nseat".to_string()], - vec!["x".repeat(129)], - (0..129).map(|index| format!("seat-{index}")).collect(), + json!(["same", "same"]), + json!([""]), + json!(["bad\nseat"]), + json!(["x".repeat(129)]), + Value::Array( + (0..129) + .map(|index| json!(format!("seat-{index}"))) + .collect(), + ), ] { - let mut invalid = provider("standard"); - invalid.notifications.suppress_seats = seats; - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); + let mut invalid = table("openrtb"); + invalid.insert( + NOTIFICATIONS_KEY.to_string(), + json!({"suppress_seats": seats}), + ); + assert!( + AuctionPlan::compile(config(vec![("one", invalid)])).is_err(), + "should refuse {seats}" + ); } } #[test] - fn typed_profile_config_rejects_unknown_fields_and_validates_aps_pairing() { - let mut pbs = provider("prebid-server"); - pbs.profile_config = serde_json::json!({"browser_only": true}); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("pbs"), pbs)]))).is_err()); + fn an_implementation_rejects_settings_it_does_not_know() { + let mut pbs = table("prebid_server"); + pbs.insert("browser_only".to_string(), json!(true)); + let error = AuctionPlan::compile(config(vec![("pbs", pbs)])) + .expect_err("should refuse a setting the implementation does not know"); + assert!( + format!("{error:?}").contains("browser_only"), + "should name the setting: {error:?}" + ); - let mut non_object = provider("standard"); - non_object.profile_config = Value::Null; + let mut aps = table("aps"); + aps.insert("inventory_domain".to_string(), json!("publisher.example")); assert!( - AuctionPlan::compile(config(BTreeMap::from([(id("standard"), non_object,)]))).is_err() + AuctionPlan::compile(config(vec![("aps", aps)])).is_err(), + "should refuse an APS inventory domain without its page origin" ); - let mut aps = provider("aps"); - aps.endpoint = "https://aps.example/e/pb/bid".to_string(); - aps.profile_config = serde_json::json!({ - "account_id": "example-account", - "inventory_domain": "publisher.example" - }); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))).is_err()); + let mut without_account = table("aps"); + without_account.remove("account_id"); + assert!( + AuctionPlan::compile(config(vec![("aps", without_account)])).is_err(), + "should refuse an APS source with no account" + ); } #[test] fn enabled_signing_requires_nonblank_existing_global_store_ids() { - let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + let mut raw = one_source(); raw.request_signing = Some(RequestSigning { enabled: true, config_store_id: " ".to_string(), @@ -1337,7 +1384,7 @@ mod tests { "should reject enabled signing without a config store ID" ); - let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + let mut raw = one_source(); raw.request_signing = Some(RequestSigning { enabled: true, config_store_id: "example-config-store".to_string(), @@ -1350,23 +1397,85 @@ mod tests { } #[test] - fn routing_signing_and_static_mediator_are_preserved_in_plan() { - let mut provider = provider("standard"); - provider.routing = RoutingMode::AllEligible; - let mut raw = config(BTreeMap::from([(id("one"), provider)])); + fn routing_signing_and_the_selected_ad_server_are_preserved_in_the_plan() { + let mut openrtb = table("openrtb"); + openrtb.insert(ROUTING_KEY.to_string(), json!("all_eligible")); + let mut raw = config(vec![("one", openrtb)]); raw.request_signing = Some(RequestSigning { enabled: true, config_store_id: "example-config-store".to_string(), secret_store_id: "example-secret-store".to_string(), }); - raw.mediator = Some(MOCK_MEDIATOR_ID.to_string()); + raw.adserver = ProviderChoice::new( + Some("adserver_mock".to_string()), + BTreeMap::from([( + "adserver_mock".to_string(), + Map::from_iter([( + ENDPOINT_KEY.to_string(), + json!("http://127.0.0.1:6767/adserver/mediate"), + )]), + )]), + ); let plan = AuctionPlan::compile(raw).expect("should compile common policies"); assert_eq!(plan.providers()[0].routing, RoutingMode::AllEligible); assert!(plan.signing_enabled()); - assert_eq!(plan.mediator(), Some(MOCK_MEDIATOR_ID)); + assert_eq!( + plan.adserver().map(|adserver| adserver.id.as_str()), + Some("adserver_mock") + ); - let mut invalid = config(BTreeMap::new()); - invalid.mediator = Some("generic-mediator".to_string()); - assert!(AuctionPlan::compile(invalid).is_err()); + let mut invalid = config(Vec::new()); + invalid.adserver = + ProviderChoice::new(Some("fictional_adserver".to_string()), BTreeMap::new()); + let error = AuctionPlan::compile(invalid) + .expect_err("should refuse an ad server this build does not have"); + assert!( + error.to_string().contains("fictional_adserver"), + "should name the ad server: {error:?}" + ); + } + + #[test] + fn an_ad_server_is_built_from_its_own_table() { + let mut raw = one_source(); + raw.adserver = ProviderChoice::new( + Some("house".to_string()), + BTreeMap::from([( + "house".to_string(), + Map::from_iter([ + (IMPLEMENTATION_KEY.to_string(), json!("adserver_mock")), + ( + ENDPOINT_KEY.to_string(), + json!("https://adserver.example/mediate"), + ), + ("timeout_ms".to_string(), json!(750)), + ]), + )]), + ); + let plan = AuctionPlan::compile(raw).expect("should compile a named ad server"); + let adserver = plan.adserver().expect("should select an ad server"); + assert_eq!(adserver.id.as_str(), "house"); + assert_eq!(adserver.implementation.id, "adserver_mock"); + + let mut unknown_setting = one_source(); + unknown_setting.adserver = ProviderChoice::new( + Some("adserver_mock".to_string()), + BTreeMap::from([( + "adserver_mock".to_string(), + Map::from_iter([ + ( + ENDPOINT_KEY.to_string(), + json!("https://adserver.example/mediate"), + ), + ("enabled".to_string(), json!(true)), + ]), + )]), + ); + let error = AuctionPlan::compile(unknown_setting) + .expect_err("should refuse a setting the ad server does not know"); + assert!( + format!("{error:?}").contains("enabled"), + "should name the setting: {error:?}" + ); } } diff --git a/crates/trusted-server-core/src/auction/profile.rs b/crates/trusted-server-core/src/auction/profile.rs deleted file mode 100644 index 51a91e69c..000000000 --- a/crates/trusted-server-core/src/auction/profile.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Compile-time `OpenRTB` profile registry and typed profile plans. - -use std::collections::BTreeMap; - -use error_stack::Report; -use serde::Deserialize; -use serde_json::{Map, Value}; - -use crate::consent_config::ConsentForwardingMode; -use crate::error::TrustedServerError; -use crate::integrations::aps::compile_profile_config as compile_aps_profile_config; -use crate::integrations::prebid::{ - BidParamOverrideEngine, BidParamOverrideRule, compile_profile_override_rules, -}; - -const STANDARD_PROFILE_ID: &str = "standard"; -const PREBID_PROFILE_ID: &str = "prebid-server"; -const APS_PROFILE_ID: &str = "aps"; -const STATIC_EXTENSION_MAX_BYTES: usize = 16 * 1024; -const STATIC_EXTENSION_MAX_DEPTH: usize = 8; -const STATIC_EXTENSION_MAX_KEYS: usize = 256; - -/// A registered profile's provider-timeout default. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProfileTimeoutDefault { - /// Inherit the configured auction timeout. - Auction, - /// Use this fixed profile timeout. - Fixed(u32), -} - -/// Compile-time profile registration. -#[derive(Clone, Copy)] -pub struct OpenRtbProfileRegistration { - /// Stable profile identifier used by configuration. - pub id: &'static str, - /// Profile timeout used when a provider has no explicit override. - pub default_timeout: ProfileTimeoutDefault, - compile: fn(&Value) -> Result>, -} - -impl core::fmt::Debug for OpenRtbProfileRegistration { - fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - formatter - .debug_struct("OpenRtbProfileRegistration") - .field("id", &self.id) - .field("default_timeout", &self.default_timeout) - .finish_non_exhaustive() - } -} - -impl OpenRtbProfileRegistration { - pub(crate) fn compile( - self, - config: &Value, - ) -> Result> { - (self.compile)(config) - } -} - -/// Immutable, typed profile behavior selected during plan compilation. -#[derive(Debug, Clone)] -pub enum CompiledOpenRtbProfile { - /// Generic `OpenRTB` 2.6 profile. - Standard(StandardProfilePlan), - /// Prebid Server compatibility profile. - PrebidServer(PrebidProfilePlan), - /// APS `OpenRTB` compatibility profile. - Aps(ApsProfilePlan), -} - -impl CompiledOpenRtbProfile { - /// Return the stable profile identifier. - #[must_use] - pub fn id(&self) -> &'static str { - match self { - Self::Standard(_) => STANDARD_PROFILE_ID, - Self::PrebidServer(_) => PREBID_PROFILE_ID, - Self::Aps(_) => APS_PROFILE_ID, - } - } - - /// Return whether this plan uses the Prebid Server profile. - #[must_use] - pub(crate) fn is_prebid_server(&self) -> bool { - matches!(self, Self::PrebidServer(_)) - } -} - -/// Validated static extension object. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct StaticExtension(Map); - -impl StaticExtension { - /// Borrow the validated extension object. - #[must_use] - pub fn as_object(&self) -> &Map { - &self.0 - } -} - -/// Compiled generic `OpenRTB` profile configuration. -#[derive(Debug, Clone, Default)] -pub struct StandardProfilePlan { - /// Static request-level extension fields. - pub request_ext: StaticExtension, - /// Static impression-level extension fields. - pub imp_ext: StaticExtension, -} - -/// Compiled Prebid profile configuration. -#[derive(Debug, Clone)] -pub struct PrebidProfilePlan { - /// Include Prebid HTTP exchange diagnostics. - pub debug: bool, - /// Set `OpenRTB` test mode. - pub test_mode: bool, - /// Optional query fragment appended to the page URL under legacy rules. - pub debug_query_params: Option, - /// Compiled override matching and merge index. - pub(crate) override_engine: BidParamOverrideEngine, - /// Consent transport policy. - pub consent_forwarding: ConsentForwardingMode, -} - -/// Compiled APS profile configuration. -#[derive(Debug, Clone)] -pub struct ApsProfilePlan { - /// APS account identifier. - pub account_id: String, - /// Include APS request/response diagnostics. - pub debug: bool, - /// Permit APS script creatives. - pub allow_script_creatives: bool, - /// Optional authorized inventory domain. - pub inventory_domain: Option, - /// Optional canonical inventory page origin. - pub inventory_page_origin: Option, -} - -#[derive(Debug, Deserialize, Default)] -#[serde(deny_unknown_fields)] -struct StandardProfileConfig { - #[serde(default)] - request_ext: Option, - #[serde(default)] - imp_ext: Option, -} - -/// Typed operator configuration compiled into a [`PrebidProfilePlan`]. -#[derive(Debug, Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub(crate) struct PrebidProfileConfig { - #[serde(default)] - debug: bool, - #[serde(default)] - test_mode: bool, - #[serde(default)] - debug_query_params: Option, - #[serde(default)] - bid_param_zone_overrides: BTreeMap>>, - #[serde(default)] - bid_param_overrides: BTreeMap>, - #[serde(default)] - bid_param_override_rules: Vec, - #[serde(default)] - consent_forwarding: ConsentForwardingMode, -} - -const PROFILE_REGISTRATIONS: [OpenRtbProfileRegistration; 3] = [ - OpenRtbProfileRegistration { - id: STANDARD_PROFILE_ID, - default_timeout: ProfileTimeoutDefault::Auction, - compile: compile_standard, - }, - OpenRtbProfileRegistration { - id: PREBID_PROFILE_ID, - default_timeout: ProfileTimeoutDefault::Fixed(1000), - compile: compile_prebid, - }, - OpenRtbProfileRegistration { - id: APS_PROFILE_ID, - default_timeout: ProfileTimeoutDefault::Fixed(800), - compile: compile_aps, - }, -]; - -/// Return the compile-time profile registry. -#[must_use] -pub fn profile_registrations() -> &'static [OpenRtbProfileRegistration] { - &PROFILE_REGISTRATIONS -} - -pub(crate) fn find_profile(id: &str) -> Option { - profile_registrations() - .iter() - .copied() - .find(|registration| registration.id == id) -} - -fn configuration_error(message: impl Into) -> Report { - Report::new(TrustedServerError::Configuration { - message: message.into(), - }) -} - -fn deserialize_profile(id: &str, value: &Value) -> Result> -where - T: for<'de> Deserialize<'de>, -{ - T::deserialize(value) - .map_err(|error| configuration_error(format!("invalid `{id}` profile_config: {error}"))) -} - -fn compile_standard(value: &Value) -> Result> { - let config: StandardProfileConfig = deserialize_profile(STANDARD_PROFILE_ID, value)?; - Ok(CompiledOpenRtbProfile::Standard(StandardProfilePlan { - request_ext: validate_static_extension("request_ext", config.request_ext)?, - imp_ext: validate_static_extension("imp_ext", config.imp_ext)?, - })) -} - -fn compile_prebid(value: &Value) -> Result> { - let config: PrebidProfileConfig = deserialize_profile(PREBID_PROFILE_ID, value)?; - let override_engine = compile_profile_override_rules( - &config.bid_param_zone_overrides, - &config.bid_param_overrides, - &config.bid_param_override_rules, - )?; - Ok(CompiledOpenRtbProfile::PrebidServer(PrebidProfilePlan { - debug: config.debug, - test_mode: config.test_mode, - debug_query_params: config.debug_query_params, - override_engine, - consent_forwarding: config.consent_forwarding, - })) -} - -fn compile_aps(value: &Value) -> Result> { - let config = compile_aps_profile_config(value.clone())?; - Ok(CompiledOpenRtbProfile::Aps(ApsProfilePlan { - account_id: config.account_id, - debug: config.debug, - allow_script_creatives: config.allow_script_creatives, - inventory_domain: config.inventory_domain, - inventory_page_origin: config.inventory_page_origin, - })) -} - -fn validate_static_extension( - field: &str, - value: Option, -) -> Result> { - let Some(value) = value else { - return Ok(StaticExtension::default()); - }; - let object = value.as_object().ok_or_else(|| { - configuration_error(format!("standard profile {field} must be an object")) - })?; - let size = serde_json::to_vec(&value) - .map_err(|error| configuration_error(format!("cannot serialize {field}: {error}")))? - .len(); - if size > STATIC_EXTENSION_MAX_BYTES { - return Err(configuration_error(format!( - "standard profile {field} exceeds {STATIC_EXTENSION_MAX_BYTES} bytes" - ))); - } - validate_extension_value(field, &value, 0)?; - reject_reserved_fields(field, object)?; - Ok(StaticExtension(object.clone())) -} - -fn validate_extension_value( - field: &str, - value: &Value, - container_depth: usize, -) -> Result<(), Report> { - match value { - Value::Object(object) => { - let container_depth = container_depth + 1; - if container_depth > STATIC_EXTENSION_MAX_DEPTH { - return Err(configuration_error(format!( - "standard profile {field} exceeds nesting depth {STATIC_EXTENSION_MAX_DEPTH}" - ))); - } - if object.len() > STATIC_EXTENSION_MAX_KEYS { - return Err(configuration_error(format!( - "standard profile {field} object exceeds {STATIC_EXTENSION_MAX_KEYS} keys" - ))); - } - for nested in object.values() { - validate_extension_value(field, nested, container_depth)?; - } - } - Value::Array(array) => { - let container_depth = container_depth + 1; - if container_depth > STATIC_EXTENSION_MAX_DEPTH { - return Err(configuration_error(format!( - "standard profile {field} exceeds nesting depth {STATIC_EXTENSION_MAX_DEPTH}" - ))); - } - for nested in array { - validate_extension_value(field, nested, container_depth)?; - } - } - _ => {} - } - Ok(()) -} - -fn reject_reserved_fields( - field: &str, - object: &Map, -) -> Result<(), Report> { - let reserved: &[&str] = match field { - "request_ext" => &["trusted_server"], - _ => &[], - }; - if let Some(key) = reserved.iter().find(|key| object.contains_key(**key)) { - return Err(configuration_error(format!( - "standard profile {field} cannot claim reserved field `{key}`" - ))); - } - Ok(()) -} diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 28f6dbd96..c55758421 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -6,11 +6,8 @@ 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, StatusCode, header}; -use serde_json::{Value, json}; - -use crate::integrations::aps::{ApsDebugRequest, parse_planned_aps_response}; -use crate::integrations::prebid::{apply_prebid_transport_headers, parse_planned_prebid_response}; +use http::{Method, Request, header}; +use serde_json::json; use crate::error::TrustedServerError; use crate::platform::{ @@ -18,25 +15,23 @@ 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, - extract_standard_response, unused_bidder_params_count, + unused_bidder_params_count, }; use super::plan::ProviderPlan; -use super::profile::CompiledOpenRtbProfile; use super::routing::{ProviderAuctionInput, RoutedAuction}; use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; -const MAX_PLANNED_RESPONSE_BYTES: usize = 1024 * 1024; - fn attach_provider_routing_metadata( response: &mut AuctionResponse, - profile: &CompiledOpenRtbProfile, + demand: &dyn CompiledDemand, input: &ProviderAuctionInput, ) { response.metadata.insert( "routing".to_string(), - json!({"unused_bidder_params_count": unused_bidder_params_count(profile, input)}), + json!({"unused_bidder_params_count": unused_bidder_params_count(demand, input)}), ); } @@ -197,27 +192,14 @@ pub(crate) struct GenericOpenRtbProvider { plan: ProviderPlan, } -/// Typed state created by and returned to one [`GenericOpenRtbProvider`]. -#[allow( - dead_code, - clippy::large_enum_variant, - reason = "typed Stage 6 state avoids provider-state confusion; Stage 7/8 replace profile variants" -)] -pub(crate) enum GenericOpenRtbParseState { - Standard { - provider_id: String, - input: ProviderAuctionInput, - }, - Prebid { - provider_id: String, - auction_id: String, - input: ProviderAuctionInput, - }, - Aps { - provider_id: String, - input: ProviderAuctionInput, - debug_request: Option, - }, +/// State created by and returned to one [`GenericOpenRtbProvider`]. +/// +/// `captured` holds whatever the demand implementation kept from its own +/// request, and only that implementation reads it back. +pub(crate) struct GenericOpenRtbParseState { + provider_id: String, + input: ProviderAuctionInput, + captured: Option>, } impl GenericOpenRtbProvider { @@ -235,23 +217,11 @@ impl GenericOpenRtbProvider { #[cfg(test)] pub(crate) fn parse_state_for_test(&self, input: ProviderAuctionInput) -> ProviderParseState { - let state = match &self.plan.profile { - CompiledOpenRtbProfile::Standard(_) => GenericOpenRtbParseState::Standard { - provider_id: self.provider_name().to_string(), - input, - }, - CompiledOpenRtbProfile::PrebidServer(_) => GenericOpenRtbParseState::Prebid { - provider_id: self.provider_name().to_string(), - auction_id: input.common_request().id.clone(), - input, - }, - CompiledOpenRtbProfile::Aps(_) => GenericOpenRtbParseState::Aps { - provider_id: self.provider_name().to_string(), - input, - debug_request: None, - }, - }; - Box::new(state) + Box::new(GenericOpenRtbParseState { + provider_id: self.provider_name().to_string(), + input, + captured: None, + }) } /// Build, register, and start exactly one routed provider request. @@ -347,20 +317,28 @@ impl GenericOpenRtbProvider { self.provider_name() ), })?; - let mut outbound = Request::builder() + let demand = self.plan.demand.as_ref(); + let mut builder = Request::builder() .method(Method::POST) .uri(self.plan.endpoint.as_str()) .header(header::CONTENT_TYPE, "application/json"); - if matches!(&self.plan.profile, CompiledOpenRtbProfile::Standard(_)) { - outbound = outbound.header(header::ACCEPT, "application/json"); + if demand.field_policy().accept_json { + builder = builder.header(header::ACCEPT, "application/json"); } - let aps_debug_body = matches!( - &self.plan.profile, - CompiledOpenRtbProfile::Aps(profile) if profile.debug - ) - .then(|| body.clone()); - let mut outbound = - outbound + if let Some(headers) = builder.headers_mut() { + demand.prepare_outbound( + headers, + DemandTransport { + headers: routed.transport_headers(), + attested_client_ip: routed.attested_client_ip(), + }, + ); + } + let captured = builder + .headers_ref() + .and_then(|headers| demand.capture_request(&body, headers)); + let outbound = + builder .body(EdgeBody::from(body)) .change_context(TrustedServerError::Auction { message: format!( @@ -368,17 +346,6 @@ impl GenericOpenRtbProvider { self.provider_name() ), })?; - let aps_debug_request = aps_debug_body - .as_deref() - .map(|body| ApsDebugRequest::capture(body, outbound.headers())); - if let CompiledOpenRtbProfile::PrebidServer(profile) = &self.plan.profile { - apply_prebid_transport_headers( - routed.prebid_transport_headers(), - &mut outbound, - profile.consent_forwarding, - routed.attested_client_ip(), - ); - } let pending = services .http_client() .send_async(PlatformHttpRequest::new(outbound, backend_name.clone())) @@ -394,29 +361,22 @@ impl GenericOpenRtbProvider { ), })); } - let parse_state = match &self.plan.profile { - CompiledOpenRtbProfile::Standard(_) => GenericOpenRtbParseState::Standard { - provider_id: self.provider_name().to_string(), - input: input.clone(), - }, - CompiledOpenRtbProfile::PrebidServer(_) => GenericOpenRtbParseState::Prebid { - provider_id: self.provider_name().to_string(), - auction_id: input.common_request().id.clone(), - input: input.clone(), - }, - CompiledOpenRtbProfile::Aps(_) => GenericOpenRtbParseState::Aps { - provider_id: self.provider_name().to_string(), - input: input.clone(), - debug_request: aps_debug_request, - }, - }; Ok(ProviderRequestOutcome::pending_with_state( pending, - Box::new(parse_state), + Box::new(GenericOpenRtbParseState { + provider_id: self.provider_name().to_string(), + input: input.clone(), + captured, + }), )) } /// 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. pub(crate) async fn parse_response_with_state( &self, response: PlatformResponse, @@ -433,153 +393,31 @@ impl GenericOpenRtbProvider { ), }) })?; - let state_provider_id = match parse_state { - GenericOpenRtbParseState::Standard { provider_id, .. } - | GenericOpenRtbParseState::Prebid { provider_id, .. } - | GenericOpenRtbParseState::Aps { provider_id, .. } => provider_id, - }; - if state_provider_id != self.provider_name() { + if parse_state.provider_id != self.provider_name() { return Err(Report::new(TrustedServerError::Auction { message: format!( "Provider {} received response state owned by provider {}", self.provider_name(), - state_provider_id + parse_state.provider_id ), })); } - if let GenericOpenRtbParseState::Prebid { - auction_id, input, .. - } = parse_state - { - let CompiledOpenRtbProfile::PrebidServer(profile) = &self.plan.profile else { - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "Provider {} received PBS response state for profile {}", - self.provider_name(), - self.plan.profile.id() - ), - })); - }; - let mut parsed = match parse_planned_prebid_response( - self.provider_name(), - profile, - input, + let demand = self.plan.demand.as_ref(); + let mut parsed = demand + .parse_response( + DemandResponse { + provider_id: self.provider_name(), + endpoint: self.plan.endpoint.as_str(), + input: &parse_state.input, + response_time_ms, + captured: parse_state.captured.as_deref(), + }, response, - response_time_ms, - auction_id, ) - .await - { - Ok(parsed) => parsed, - Err(error) => { - log::warn!( - "Provider '{}' PBS response parse failed: {:?}", - self.provider_name(), - error - ); - AuctionResponse::error(self.provider_name(), response_time_ms) - .with_metadata("error_type", json!("parse_response")) - } - }; - apply_notification_policy(&mut parsed.bids, &self.plan.notifications); - attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); - return Ok(parsed); - } - - if let GenericOpenRtbParseState::Aps { - input, - debug_request, - .. - } = parse_state - { - let CompiledOpenRtbProfile::Aps(profile) = &self.plan.profile else { - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "Provider {} received APS response state for profile {}", - self.provider_name(), - self.plan.profile.id() - ), - })); - }; - let mut parsed = match parse_planned_aps_response( - self.provider_name(), - profile, - self.plan.endpoint.as_str(), - input, - response, - response_time_ms, - debug_request.clone(), - ) - .await - { - Ok(parsed) => parsed, - Err(error) => { - log::warn!( - "Provider '{}' APS response parse failed: {:?}", - self.provider_name(), - error - ); - let mut parsed = AuctionResponse::error(self.provider_name(), response_time_ms) - .with_metadata("error_type", json!("parse_response")); - attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); - parsed - } - }; - apply_notification_policy(&mut parsed.bids, &self.plan.notifications); - return Ok(parsed); - } - - let response = response.response; - let status = response.status(); - let GenericOpenRtbParseState::Standard { input, .. } = parse_state else { - unreachable!("profile-specific states are handled before standard parsing"); - }; - if status == StatusCode::NO_CONTENT { - let mut parsed = AuctionResponse::no_bid(self.provider_name(), response_time_ms); - attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); - return Ok(parsed); - } - if !status.is_success() { - if status.is_redirection() { - log::warn!( - "Provider '{}' returned a redirect; generic OpenRTB redirects are refused", - self.provider_name() - ); - } - let mut parsed = AuctionResponse::error(self.provider_name(), response_time_ms) - .with_metadata("error_type", json!("http_status")) - .with_metadata("http_status", json!(status.as_u16())); - attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); - return Ok(parsed); - } - - let body = response - .into_body() - .into_bytes_bounded(MAX_PLANNED_RESPONSE_BYTES) - .await - .change_context(TrustedServerError::Auction { - message: format!("Provider {} response body failed", self.provider_name()), - })?; - let value: Value = match serde_json::from_slice(&body) { - Ok(value) => value, - Err(error) => { - log::warn!( - "Provider '{}' response JSON was invalid: {}", - self.provider_name(), - error - ); - let mut parsed = AuctionResponse::error(self.provider_name(), response_time_ms) - .with_metadata("error_type", json!("parse_response")); - attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); - return Ok(parsed); - } - }; - - let mut parsed = - extract_standard_response(self.provider_name(), input, &value, response_time_ms); + .await?; apply_notification_policy(&mut parsed.bids, &self.plan.notifications); - attach_provider_routing_metadata(&mut parsed, &self.plan.profile, input); + attach_provider_routing_metadata(&mut parsed, demand, &parse_state.input); Ok(parsed) } } diff --git a/crates/trusted-server-core/src/auction/routing.rs b/crates/trusted-server-core/src/auction/routing.rs index 61b2302cc..02dfa5d09 100644 --- a/crates/trusted-server-core/src/auction/routing.rs +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -25,7 +25,7 @@ pub(crate) struct RoutedAuction { inputs: Vec, skipped_no_eligible_provider_ids: Vec, diagnostics: RoutingDiagnostics, - transport_headers: PrebidTransportHeaders, + transport_headers: TransportHeaders, attested_client_ip: Option, dnt: Option, } @@ -47,7 +47,7 @@ impl RoutedAuction { } /// Request headers approved for later Prebid transport forwarding. - pub(crate) fn prebid_transport_headers(&self) -> &PrebidTransportHeaders { + pub(crate) fn transport_headers(&self) -> &TransportHeaders { &self.transport_headers } @@ -121,7 +121,7 @@ impl RoutingDiagnostics { /// Provider-local immutable auction input. #[derive(Debug, Clone)] -pub(crate) struct ProviderAuctionInput { +pub struct ProviderAuctionInput { provider_id: ProviderId, #[cfg_attr( not(test), @@ -136,7 +136,9 @@ pub(crate) struct ProviderAuctionInput { } impl ProviderAuctionInput { - pub(crate) fn provider_id(&self) -> &ProviderId { + /// The configured name of the demand source this input is routed to. + #[must_use] + pub fn provider_id(&self) -> &ProviderId { &self.provider_id } @@ -146,39 +148,50 @@ impl ProviderAuctionInput { } /// Common privacy-approved request data. Its slot list is always empty. - pub(crate) fn common_request(&self) -> &AuctionRequest { + #[must_use] + pub fn common_request(&self) -> &AuctionRequest { &self.common_request } - pub(crate) fn slots(&self) -> &[ProviderSlotInput] { + /// The eligible slots routed to this demand source. + #[must_use] + pub fn slots(&self) -> &[ProviderSlotInput] { &self.slots } } /// One eligible slot with only the demand assigned to this provider. #[derive(Debug, Clone)] -pub(crate) struct ProviderSlotInput { +pub struct ProviderSlotInput { slot: AdSlot, bidder_params: BTreeMap, - prebid_zone: Option, + zone: Option, trusted_stored_request: bool, } impl ProviderSlotInput { /// Common slot facts. The legacy `bidders` map is always empty. - pub(crate) fn slot(&self) -> &AdSlot { + #[must_use] + pub fn slot(&self) -> &AdSlot { &self.slot } - pub(crate) fn bidder_params(&self) -> &BTreeMap { + /// The bidder parameters routed to this demand source for this slot. + #[must_use] + pub fn bidder_params(&self) -> &BTreeMap { &self.bidder_params } - pub(crate) fn prebid_zone(&self) -> Option<&str> { - self.prebid_zone.as_deref() + /// The zone the browser named for this slot, where one was admitted. + #[must_use] + pub fn zone(&self) -> Option<&str> { + self.zone.as_deref() } - pub(crate) fn has_trusted_stored_request(&self) -> bool { + /// Whether this slot carries no bidder parameters and is sent as a stored + /// request by an implementation that serves them. + #[must_use] + pub fn is_stored_request(&self) -> bool { self.trusted_stored_request } } @@ -188,27 +201,35 @@ impl ProviderSlotInput { /// Values remain as raw [`HeaderValue`] instances so non-ASCII bytes retain /// the same legacy handling. Client-supplied `X-Forwarded-For` is never read. #[derive(Debug, Clone, Default)] -pub(crate) struct PrebidTransportHeaders { +pub struct TransportHeaders { cookie: Option, user_agent: Option, referer: Option, accept_language: Option, } -impl PrebidTransportHeaders { - pub(crate) fn cookie(&self) -> Option<&HeaderValue> { +impl TransportHeaders { + /// The admitted `Cookie` header. + #[must_use] + pub fn cookie(&self) -> Option<&HeaderValue> { self.cookie.as_ref() } - pub(crate) fn user_agent(&self) -> Option<&HeaderValue> { + /// The admitted `User-Agent` header. + #[must_use] + pub fn user_agent(&self) -> Option<&HeaderValue> { self.user_agent.as_ref() } - pub(crate) fn referer(&self) -> Option<&HeaderValue> { + /// The admitted `Referer` header. + #[must_use] + pub fn referer(&self) -> Option<&HeaderValue> { self.referer.as_ref() } - pub(crate) fn accept_language(&self) -> Option<&HeaderValue> { + /// The admitted `Accept-Language` header. + #[must_use] + pub fn accept_language(&self) -> Option<&HeaderValue> { self.accept_language.as_ref() } @@ -248,14 +269,14 @@ impl TrustedProviderRoutes { struct NormalizedSlotDemand { bidder_params: BTreeMap, stored_request: bool, - prebid_zone: Option, + zone: Option, } #[derive(Debug)] struct ProviderInputBuilder { provider_id: ProviderId, timeout_ms: u32, - is_prebid: bool, + serves_stored_requests: bool, routing: RoutingMode, slots: Vec, } @@ -292,7 +313,7 @@ pub(crate) fn route_auction_with_trusted_routes( ) -> RoutedAuction { let slots = std::mem::take(&mut request.slots); let common_request = request; - let transport_headers = PrebidTransportHeaders::snapshot(inbound_request); + let transport_headers = TransportHeaders::snapshot(inbound_request); let dnt = inbound_request .headers() .get("dnt") @@ -306,7 +327,7 @@ pub(crate) fn route_auction_with_trusted_routes( .map(|provider| ProviderInputBuilder { provider_id: provider.id.clone(), timeout_ms: provider.timeout_ms, - is_prebid: provider.profile.is_prebid_server(), + serves_stored_requests: provider.implementation.serves_stored_requests, routing: provider.routing, slots: Vec::new(), }) @@ -346,7 +367,7 @@ pub(crate) fn route_auction_with_trusted_routes( let bidder_params = std::mem::take(&mut routed_params[provider_index]); let trusted_route = trusted_provider_indices.contains(&provider_index); let trusted_stored_request = - builder.is_prebid && demand.stored_request && bidder_params.is_empty(); + builder.serves_stored_requests && demand.stored_request && bidder_params.is_empty(); let include = builder.routing == RoutingMode::AllEligible || !bidder_params.is_empty() || trusted_stored_request @@ -357,9 +378,9 @@ pub(crate) fn route_auction_with_trusted_routes( builder.slots.push(ProviderSlotInput { slot: common_slot.clone(), bidder_params, - prebid_zone: builder - .is_prebid - .then(|| demand.prebid_zone.clone()) + zone: builder + .serves_stored_requests + .then(|| demand.zone.clone()) .flatten(), trusted_stored_request, }); @@ -462,7 +483,7 @@ fn normalize_envelope(envelope: &Value) -> Option { { return None; } - let prebid_zone = match object.get(ZONE_FIELD) { + let zone = match object.get(ZONE_FIELD) { None => None, Some(Value::String(zone)) if zone.len() <= MAX_PREBID_ZONE_BYTES => Some(zone.clone()), Some(_) => return None, @@ -470,14 +491,14 @@ fn normalize_envelope(envelope: &Value) -> Option { let Some(raw_params) = object.get(BIDDER_PARAMS_FIELD) else { return Some(NormalizedSlotDemand { stored_request: true, - prebid_zone, + zone, ..Default::default() }); }; if raw_params.is_null() { return Some(NormalizedSlotDemand { stored_request: true, - prebid_zone, + zone, ..Default::default() }); } @@ -485,7 +506,7 @@ fn normalize_envelope(envelope: &Value) -> Option { if params.is_empty() { return Some(NormalizedSlotDemand { stored_request: true, - prebid_zone, + zone, ..Default::default() }); } @@ -504,7 +525,7 @@ fn normalize_envelope(envelope: &Value) -> Option { Some(NormalizedSlotDemand { bidder_params, stored_request: false, - prebid_zone, + zone, }) } @@ -517,97 +538,57 @@ mod tests { use std::str::FromStr as _; use super::*; - use crate::auction::plan::{ - AuctionPlanConfig, BidderRouteConfig, NotificationConfig, ProviderConfig, - }; + use crate::auction::plan::BidderRouteConfig; + use crate::auction::test_support::{demand_table, plan_config}; use crate::auction::types::{AdFormat, DeviceInfo, PublisherInfo, SiteInfo, UserInfo}; use http::HeaderName; use serde_json::{Map, json}; - fn provider(profile: &str, routing: RoutingMode) -> ProviderConfig { - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: profile.to_string(), - endpoint: format!("https://{profile}.example.test/openrtb"), - timeout_ms: None, - routing, - notifications: NotificationConfig::default(), - profile_config: if profile == "aps" { - json!({"account_id": "example-account"}) - } else { - json!({}) - }, + fn provider(implementation: &str, routing: RoutingMode) -> Map { + let mut table = demand_table( + implementation, + &format!("https://{implementation}.example.test/openrtb"), + ); + if routing == RoutingMode::AllEligible { + table.insert("routing".to_string(), json!("all_eligible")); } + table + } + + fn routing_plan_config(aps_routing: RoutingMode) -> crate::auction::plan::AuctionPlanConfig { + let mut config = plan_config(vec![ + ("aps_primary", provider("aps", aps_routing)), + ("pbs_a", provider("prebid_server", RoutingMode::Explicit)), + ("pbs_b", provider("prebid_server", RoutingMode::Explicit)), + ("openrtb_direct", provider("openrtb", RoutingMode::Explicit)), + ]); + config.timeout_ms = 900; + config } fn plan() -> AuctionPlan { - AuctionPlan::compile(AuctionPlanConfig { - timeout_ms: 900, - providers: BTreeMap::from([ - ( - ProviderId::from_str("aps-primary").expect("should parse provider"), - provider("aps", RoutingMode::AllEligible), - ), - ( - ProviderId::from_str("pbs-a").expect("should parse provider"), - provider("prebid-server", RoutingMode::Explicit), - ), - ( - ProviderId::from_str("pbs-b").expect("should parse provider"), - provider("prebid-server", RoutingMode::Explicit), - ), - ( - ProviderId::from_str("standard-direct").expect("should parse provider"), - provider("standard", RoutingMode::Explicit), - ), - ]), - bidders: BTreeMap::from([ - ( - BidderId::from_str("alpha").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("pbs-a").expect("should parse provider"), - }, - ), - ( - BidderId::from_str("beta").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("standard-direct") - .expect("should parse provider"), - }, - ), - ]), - mediator: None, - request_signing: None, - }) - .expect("should compile plan") + let mut config = routing_plan_config(RoutingMode::AllEligible); + config.bidders = BTreeMap::from([ + ( + BidderId::from_str("alpha").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("pbs_a").expect("should parse provider"), + }, + ), + ( + BidderId::from_str("beta").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("openrtb_direct") + .expect("should parse provider"), + }, + ), + ]); + AuctionPlan::compile(config).expect("should compile plan") } fn explicit_plan() -> AuctionPlan { - AuctionPlan::compile(AuctionPlanConfig { - timeout_ms: 900, - providers: BTreeMap::from([ - ( - ProviderId::from_str("aps-primary").expect("should parse provider"), - provider("aps", RoutingMode::Explicit), - ), - ( - ProviderId::from_str("pbs-a").expect("should parse provider"), - provider("prebid-server", RoutingMode::Explicit), - ), - ( - ProviderId::from_str("pbs-b").expect("should parse provider"), - provider("prebid-server", RoutingMode::Explicit), - ), - ( - ProviderId::from_str("standard-direct").expect("should parse provider"), - provider("standard", RoutingMode::Explicit), - ), - ]), - bidders: BTreeMap::new(), - mediator: None, - request_signing: None, - }) - .expect("should compile explicit plan") + AuctionPlan::compile(routing_plan_config(RoutingMode::Explicit)) + .expect("should compile explicit plan") } fn slot(bidders: HashMap) -> AdSlot { @@ -697,15 +678,15 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["aps-primary", "pbs-a", "pbs-b"], + vec!["aps_primary", "pbs_a", "pbs_b"], "{name} should fan out to both PBS providers while APS remains all-eligible" ); assert!( - input(&routed, "pbs-a").slots()[0].has_trusted_stored_request(), + input(&routed, "pbs_a").slots()[0].is_stored_request(), "{name} should create stored intent" ); assert!( - input(&routed, "pbs-b").slots()[0].has_trusted_stored_request(), + input(&routed, "pbs_b").slots()[0].is_stored_request(), "{name} should create stored intent for every PBS provider" ); } @@ -720,11 +701,11 @@ mod tests { None, ); assert!( - input(&routed, "pbs-a").slots()[0].has_trusted_stored_request(), + input(&routed, "pbs_a").slots()[0].is_stored_request(), "empty canonical demand should preserve stored-request behavior" ); assert!( - input(&routed, "pbs-b").slots()[0].has_trusted_stored_request(), + input(&routed, "pbs_b").slots()[0].is_stored_request(), "empty canonical demand should fan out to same-profile PBS plans" ); } @@ -773,13 +754,13 @@ mod tests { ); assert!( routed.inputs().iter().all(|provider| { - provider.provider_id().as_str() != "pbs-a" - && provider.provider_id().as_str() != "pbs-b" + provider.provider_id().as_str() != "pbs_a" + && provider.provider_id().as_str() != "pbs_b" }), "{name} should not produce stored or inline PBS demand" ); assert_eq!( - input(&routed, "standard-direct").slots()[0] + input(&routed, "openrtb_direct").slots()[0] .bidder_params() .len(), 1, @@ -789,7 +770,7 @@ mod tests { routed .inputs() .iter() - .any(|provider| provider.provider_id().as_str() == "aps-primary"), + .any(|provider| provider.provider_id().as_str() == "aps_primary"), "{name} should preserve independent all-eligible participation" ); } @@ -864,7 +845,7 @@ mod tests { ); assert_eq!( routed.inputs()[0].provider_id().as_str(), - "aps-primary", + "aps_primary", "unknown demand should not cause PBS fallback" ); } @@ -890,7 +871,7 @@ mod tests { None, ); assert_eq!( - input(&routed, "pbs-a").slots()[0].bidder_params() + input(&routed, "pbs_a").slots()[0].bidder_params() [&BidderId::from_str("alpha").expect("should parse bidder")]["source"], expected_source, "{name} should follow deterministic collision semantics" @@ -959,10 +940,10 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["aps-primary", "pbs-a", "pbs-b", "standard-direct"], + vec!["aps_primary", "pbs_a", "pbs_b", "openrtb_direct"], "inputs should follow deterministic provider-ID order" ); - let aps = input(&routed, "aps-primary") + let aps = input(&routed, "aps_primary") .slots() .first() .expect("should have slot"); @@ -970,20 +951,20 @@ mod tests { aps.bidder_params().is_empty(), "APS must receive no foreign params" ); - assert_eq!(aps.prebid_zone(), None, "APS must receive no Prebid zone"); - let pbs_a = &input(&routed, "pbs-a").slots()[0]; + assert_eq!(aps.zone(), None, "APS must receive no Prebid zone"); + let pbs_a = &input(&routed, "pbs_a").slots()[0]; assert_eq!(pbs_a.bidder_params().len(), 1); assert!( - !pbs_a.has_trusted_stored_request(), + !pbs_a.is_stored_request(), "inline params should win for this PBS provider" ); - assert_eq!(pbs_a.prebid_zone(), Some("home")); - let pbs_b = &input(&routed, "pbs-b").slots()[0]; + assert_eq!(pbs_a.zone(), Some("home")); + let pbs_b = &input(&routed, "pbs_b").slots()[0]; assert!(pbs_b.bidder_params().is_empty()); - assert!(pbs_b.has_trusted_stored_request()); - let direct = &input(&routed, "standard-direct").slots()[0]; + assert!(pbs_b.is_stored_request()); + let direct = &input(&routed, "openrtb_direct").slots()[0]; assert_eq!(direct.bidder_params().len(), 1); - assert!(direct.prebid_zone().is_none()); + assert!(direct.zone().is_none()); for provider in routed.inputs() { assert!(provider.common_request().slots.is_empty()); assert!(provider.slots()[0].slot().bidders.is_empty()); @@ -1060,7 +1041,7 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["aps-primary", "pbs-a", "pbs-b", "standard-direct"], + vec!["aps_primary", "pbs_a", "pbs_b", "openrtb_direct"], "no-banner auction should retain every provider's deterministic skip outcome" ); } @@ -1082,7 +1063,7 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["pbs-a", "pbs-b", "standard-direct"], + vec!["pbs_a", "pbs_b", "openrtb_direct"], "explicit providers with no routed demand should be retained as skipped" ); } @@ -1090,9 +1071,9 @@ mod tests { #[test] fn trusted_routes_admit_explicit_aps_and_standard_and_ignore_unknown_provider() { let trusted_routes = TrustedProviderRoutes::new(vec![vec![ - ProviderId::from_str("aps-primary").expect("should parse provider"), - ProviderId::from_str("standard-direct").expect("should parse provider"), - ProviderId::from_str("unknown-provider").expect("should parse provider"), + ProviderId::from_str("aps_primary").expect("should parse provider"), + ProviderId::from_str("openrtb_direct").expect("should parse provider"), + ProviderId::from_str("unknown_provider").expect("should parse provider"), ]]); let routed = route_auction_with_trusted_routes( request(vec![slot(HashMap::from([( @@ -1110,7 +1091,7 @@ mod tests { .iter() .map(|input| input.provider_id().as_str()) .collect::>(), - vec!["aps-primary", "standard-direct"], + vec!["aps_primary", "openrtb_direct"], "only known server-owned provider routes should admit explicit providers" ); assert_eq!( @@ -1124,7 +1105,7 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["pbs-a", "pbs-b"], + vec!["pbs_a", "pbs_b"], "unrouted explicit providers should retain skip outcomes" ); } @@ -1170,7 +1151,7 @@ mod tests { &plan(), Some(attested), ); - let headers = routed.prebid_transport_headers(); + let headers = routed.transport_headers(); assert_eq!(headers.cookie(), Some(&HeaderValue::from_static("first=1"))); assert_eq!( headers.user_agent().expect("should retain UA").as_bytes(), @@ -1190,8 +1171,8 @@ mod tests { assert_eq!(routed.dnt(), Some(true)); for provider in routed.inputs() { let expected_timeout = match provider.provider_id().as_str() { - "aps-primary" => 800, - id if id.starts_with("pbs-") => 1000, + "aps_primary" => 800, + id if id.starts_with("pbs_") => 1000, _ => 900, }; assert_eq!(provider.timeout_ms(), expected_timeout); diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index 9b639025a..fd977be9a 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -76,7 +76,7 @@ impl AuctionTerminalStatus { pub struct AbandonedProviderCall { /// Provider name. pub provider: String, - /// Provider role, usually `bidder` or `mediator`. + /// Provider role, usually `bidder` or `adserver`. pub provider_role: &'static str, /// Optional elapsed time for this provider at abandonment. pub response_time_ms: Option, @@ -313,7 +313,7 @@ pub struct AuctionEventRow { pub winning_bid_count: Option, /// Provider name. pub provider: Option, - /// `bidder` or `mediator`. + /// `bidder` or `adserver`. pub provider_role: Option, /// Provider-call status. pub status: Option, @@ -545,13 +545,13 @@ pub fn build_auction_events( &result.provider_responses, "bidder", ); - if let Some(mediator_response) = &result.mediator_response { + if let Some(adserver_response) = &result.adserver_response { push_provider_row( &mut rows, &observation, &event_ts, - mediator_response, - "mediator", + adserver_response, + "adserver", ); } push_bid_rows( @@ -744,14 +744,14 @@ fn push_bid_rows( } } - if let Some(mediator_response) = &result.mediator_response { + if let Some(adserver_response) = &result.adserver_response { for (slot_id, winning) in &result.winning_bids { if delivered_winner_slots.is_some_and(|slots| !slots.contains(slot_id)) || matched_wins.contains(slot_id) { continue; } - if mediator_response + if adserver_response .bids .iter() .any(|bid| bid_matches_winning_bid(bid, winning)) @@ -760,7 +760,7 @@ fn push_bid_rows( observation, event_ts, request, - &mediator_response.provider, + &adserver_response.provider, winning, 1, winning.price, @@ -1114,7 +1114,7 @@ mod tests { let winning = provider_success.bids[0].clone(); let result = OrchestrationResult { provider_responses: vec![provider_success, provider_no_bid, provider_error], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), winning)]), total_time_ms: 99, metadata: HashMap::new(), @@ -1180,7 +1180,7 @@ mod tests { let provider = AuctionResponse::success("aps-primary", vec![aps_bid.clone()], 12); let result = OrchestrationResult { provider_responses: vec![provider], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), total_time_ms: 12, metadata: HashMap::new(), @@ -1216,7 +1216,7 @@ mod tests { vec![fallback_bid.clone()], 12, )], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), fallback_bid)]), total_time_ms: 12, metadata: HashMap::new(), @@ -1240,15 +1240,15 @@ mod tests { } #[test] - fn mediated_aps_telemetry_retains_provider_upstream_seat_and_delivery_identity() { + fn adserver_aps_telemetry_retains_provider_upstream_seat_and_delivery_identity() { let request = test_request("ts-ec-derived-id"); let mut aps_bid = bid("slot-1", "aps", Some("ad-1"), Some(1.25)); aps_bid.returned_seat = Some("upstream-seat".to_string()); let provider = AuctionResponse::success("aps-primary", vec![aps_bid.clone()], 12); - let mediator = AuctionResponse::success("adserver_mock", vec![aps_bid.clone()], 3); + let adserver = AuctionResponse::success("adserver_mock", vec![aps_bid.clone()], 3); let result = OrchestrationResult { provider_responses: vec![provider], - mediator_response: Some(mediator), + adserver_response: Some(adserver), winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), total_time_ms: 15, metadata: HashMap::new(), @@ -1288,7 +1288,7 @@ mod tests { ); let result = OrchestrationResult { provider_responses: vec![provider_success.clone()], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), provider_success.bids[0].clone())]), total_time_ms: 42, metadata: HashMap::new(), @@ -1329,7 +1329,7 @@ mod tests { .with_metadata("status", json!(403)); let result = OrchestrationResult { provider_responses: vec![provider_http_error], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::new(), total_time_ms: 12, metadata: HashMap::new(), @@ -1358,17 +1358,17 @@ mod tests { } #[test] - fn mediated_win_marks_original_bid_once() { + fn adserver_win_marks_original_bid_once() { let request = test_request("req"); let original_bid = bid("slot-1", "kargo", Some("ad-1"), None); let provider_success = AuctionResponse::success("prebid", vec![original_bid], 42); - let mediator_bid = bid("slot-1", "kargo", Some("ad-1"), Some(2.0)); - let mediator_response = - AuctionResponse::success("adserver_mock", vec![mediator_bid.clone()], 15); + let adserver_bid = bid("slot-1", "kargo", Some("ad-1"), Some(2.0)); + let adserver_response = + AuctionResponse::success("adserver_mock", vec![adserver_bid.clone()], 15); let result = OrchestrationResult { provider_responses: vec![provider_success], - mediator_response: Some(mediator_response), - winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]), + adserver_response: Some(adserver_response), + winning_bids: HashMap::from([("slot-1".to_owned(), adserver_bid)]), total_time_ms: 80, metadata: HashMap::new(), }; @@ -1393,12 +1393,12 @@ mod tests { assert_eq!( winning_rows[0].provider.as_deref(), Some("prebid"), - "should mark original provider row when mediator winner matches" + "should mark original provider row when adserver winner matches" ); assert_eq!( winning_rows[0].price_cpm, Some(2.0), - "should copy mediator decoded price onto original null-price bid" + "should copy adserver decoded price onto original null-price bid" ); } @@ -1407,7 +1407,7 @@ mod tests { let request = test_request("ts-ec-derived-id"); let result = OrchestrationResult { provider_responses: Vec::new(), - mediator_response: None, + adserver_response: None, winning_bids: HashMap::new(), total_time_ms: 1, metadata: HashMap::new(), diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index a83f8899a..fff599fd4 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -119,3 +119,89 @@ pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { context: HashMap::new(), } } + +/// One `[demand.]` table naming an implementation, for tests that need a +/// compiled plan. +pub(crate) fn demand_table( + implementation: &str, + endpoint: &str, +) -> serde_json::Map { + let mut table = serde_json::Map::from_iter([ + ("implementation".to_string(), json!(implementation)), + ("endpoint".to_string(), json!(endpoint)), + ]); + if implementation == "aps" { + table.insert("account_id".to_string(), json!("example-account")); + } + table +} + +/// An `[demand]` table selecting every name given, in the order given. +pub(crate) fn demand_selection( + tables: Vec<(&str, serde_json::Map)>, +) -> crate::provider_table::ProviderList { + let selected = tables + .iter() + .map(|(name, _)| (*name).to_string()) + .collect::>(); + let tables = tables + .into_iter() + .map(|(name, table)| (name.to_string(), table)) + .collect::>(); + crate::provider_table::ProviderList::new(selected, tables) +} + +/// A plan configuration selecting the named demand tables, with every +/// implementation the built-in builders register. +pub(crate) fn plan_config( + tables: Vec<(&str, serde_json::Map)>, +) -> crate::auction::plan::AuctionPlanConfig { + let builders = crate::integrations::all_builders(&[]).collect::>(); + crate::auction::plan::AuctionPlanConfig { + timeout_ms: 1000, + demand: demand_selection(tables), + demand_implementations: builders + .iter() + .filter_map(crate::integrations::IntegrationBuilder::demand) + .collect(), + adserver_implementations: builders + .iter() + .filter_map(crate::integrations::IntegrationBuilder::adserver) + .collect(), + ..crate::auction::plan::AuctionPlanConfig::default() + } +} + +/// A `[demand]` selection of ordinary `OpenRTB` sources under the names given, +/// each taking every eligible slot. +pub(crate) fn demand_named(names: &[&str]) -> crate::provider_table::ProviderList { + demand_selection( + names + .iter() + .map(|name| { + let mut table = demand_table( + "openrtb", + &format!("https://{name}.example/openrtb2/auction"), + ); + table.insert("routing".to_string(), json!("all_eligible")); + (*name, table) + }) + .collect(), + ) +} + +/// The legacy test orchestrator's configuration for these settings, carrying +/// the demand source names `[demand] provider` selects. +/// +/// Production compiles its sources from the plan instead, so this exists only +/// so the parity tests can drive the pre-plan orchestrator. +pub(crate) fn legacy_auction_config(settings: &Settings) -> crate::auction::AuctionConfig { + let mut config = settings.auction.clone(); + config.provider_names = settings + .demand + .selected() + .into_iter() + .map(str::to_string) + .collect(); + config +} diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 406915706..6ad13f73c 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,11 +1,14 @@ //! Core types for auction requests and responses. use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt as _, bail, ensure}; use http::Request; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use crate::auction::context::ContextValue; +use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -129,14 +132,14 @@ pub struct SiteInfo { /// (DNT, User-Agent, cookies, X-* customs) directly off it. /// /// In the **collect path** ([`collect_dispatched_auction`][collect]) the -/// mediator is invoked with a synthetic placeholder request +/// ad server is invoked with a synthetic placeholder request /// (`https://placeholder.invalid/`), because the real client request has /// already been consumed by `send_async` during dispatch and the host pipeline -/// can't lend it across the `.await`. **Mediators must not depend on reading +/// can't lend it across the `.await`. **Ad servers must not depend on reading /// client state from `context.request`** — the placeholder has none of the -/// real headers. If a future mediator needs that data, snapshot it into a new +/// real headers. If a future ad server needs that data, snapshot it into a new /// field on this struct at dispatch time and stash it on the -/// [`DispatchedAuction`] token so collect can attach it to the mediator's +/// [`DispatchedAuction`] token so collect can attach it to the ad server's /// context. See /// (P2-1) for the open follow-up. /// @@ -154,15 +157,15 @@ pub struct AuctionContext<'a> { /// encode timers. Providers that register a backend should use this value /// for transport timers while retaining `timeout_ms` for logical policy. pub transport_timeout_ms: u32, - /// Provider responses from the bidding phase, used by mediators. - /// This is `None` for regular bidders and `Some` when calling a mediator. + /// Provider responses from the bidding phase, used by ad servers. + /// This is `None` for regular bidders and `Some` when calling an ad server. pub provider_responses: Option<&'a [AuctionResponse]>, /// Platform services (config store, secret store, etc.) for use by providers. pub services: &'a RuntimeServices, } -/// URL used by the orchestrator when invoking a mediator from the collect -/// path. Providers can `debug_assert` against this value to catch a mediator +/// URL used by the orchestrator when invoking an ad server from the collect +/// path. Providers can `debug_assert` against this value to catch an ad server /// that has accidentally started depending on `context.request` carrying real /// client headers. pub const MEDIATOR_PLACEHOLDER_URL: &str = "https://placeholder.invalid/"; @@ -182,56 +185,158 @@ pub struct AuctionResponse { pub metadata: HashMap, } -/// APS creative tag type accepted by the Trusted Server renderer. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ApsTagType { - /// APS loads the creative URL in a nested iframe. - Iframe, - /// APS fetches creative HTML and executes it in its nested renderer frame. - Script, -} - -/// Version 1 APS renderer descriptor shared with browser clients. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApsRendererV1 { - /// Renderer contract version. - pub version: u8, - /// APS account identifier used to initialize the fixed runner. - pub account_id: String, - /// Selected `OpenRTB` bid identifier. - pub bid_id: String, - /// Optional `OpenRTB` creative identifier. - #[serde(skip_serializing_if = "Option::is_none")] - pub creative_id: Option, - /// APS creative delivery mode. - pub tag_type: ApsTagType, - /// HTTPS creative URL consumed by the fixed APS runner. - pub creative_url: String, - /// Base64-encoded exact one-bid APS response envelope. - pub aax_response: String, - /// Creative width. - pub width: u32, - /// Creative height. - pub height: u32, -} +/// Wire key carrying the renderer type tag. +/// +/// A payload may not use this key, since it would collide with the tag when +/// the descriptor is serialized flat. +const RENDERER_TYPE_KEY: &str = "type"; -/// Typed browser renderer capability carried by a bid. +/// Browser renderer capability carried by a bid: a type tag and the payload +/// the auction provider that produced the bid defines. +/// +/// Serialized flat, as `{"type": "", ...payload}`, so a page receives +/// the same bytes whether the provider lives in core or in its own crate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum BidRenderer { - /// APS renderer version 1. - Aps(ApsRendererV1), +pub struct BidRenderer { + #[serde(rename = "type")] + renderer_type: String, + #[serde(flatten)] + payload: serde_json::Map, } impl BidRenderer { - /// Return the APS renderer descriptor when this is an APS renderer. + /// Build a descriptor from a type tag and the provider's JSON payload. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Auction`] when `payload` is not a JSON + /// object, or when it carries its own `type` key, which would collide with + /// the tag once the descriptor is serialized flat. + /// + /// # Examples + /// + /// ``` + /// use serde_json::json; + /// use trusted_server_core::auction::types::BidRenderer; + /// + /// let renderer = BidRenderer::new("example", json!({ "version": 1 })) + /// .expect("should accept an object payload"); + /// assert_eq!(renderer.renderer_type(), "example"); + /// ``` + pub fn new( + renderer_type: &str, + payload: serde_json::Value, + ) -> Result> { + let serde_json::Value::Object(payload) = payload else { + bail!(TrustedServerError::Auction { + message: format!("Renderer '{renderer_type}' payload must be a JSON object"), + }); + }; + ensure!( + !payload.contains_key(RENDERER_TYPE_KEY), + TrustedServerError::Auction { + message: format!( + "Renderer '{renderer_type}' payload must not carry a '{RENDERER_TYPE_KEY}' key" + ), + } + ); + Ok(Self { + renderer_type: renderer_type.to_string(), + payload, + }) + } + + /// Build a descriptor by serializing a provider's own payload type. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Auction`] when `payload` cannot be + /// serialized, and when the serialized form is rejected by + /// [`new`](Self::new). + /// + /// # Examples + /// + /// ``` + /// use serde::Serialize; + /// use trusted_server_core::auction::types::BidRenderer; + /// + /// #[derive(Serialize)] + /// struct ExampleRendererV1 { + /// version: u8, + /// } + /// + /// let renderer = BidRenderer::from_typed("example", &ExampleRendererV1 { version: 1 }) + /// .expect("should accept a struct payload"); + /// assert_eq!(renderer.renderer_type(), "example"); + /// ``` + pub fn from_typed( + renderer_type: &str, + payload: &T, + ) -> Result> { + let payload = + serde_json::to_value(payload).change_context(TrustedServerError::Auction { + message: format!("Failed to serialize renderer '{renderer_type}' payload"), + })?; + Self::new(renderer_type, payload) + } + + /// Return the renderer type tag a page reads to select its renderer. #[must_use] - pub fn as_aps(&self) -> Option<&ApsRendererV1> { - match self { - Self::Aps(renderer) => Some(renderer), + pub fn renderer_type(&self) -> &str { + &self.renderer_type + } + + /// Deserialize the payload into the provider's own descriptor type. + /// + /// Returns `None` when the descriptor carries a different tag, and when the + /// payload does not match `T`. + /// + /// Clones the whole payload map and deserializes all of it, so use + /// [`payload_field`](Self::payload_field) when the caller wants one field. + /// An APS payload carries a base64 encoding of a creative envelope of up + /// to 256 KB. + #[must_use] + pub fn payload_as(&self, renderer_type: &str) -> Option { + if self.renderer_type != renderer_type { + return None; + } + serde_json::from_value(serde_json::Value::Object(self.payload.clone())).ok() + } + + /// Borrow one field of the payload, copying nothing. + /// + /// Returns `None` when the descriptor carries a different tag, and when + /// the payload has no such key. `key` is the wire key, so a payload type + /// that renames its fields for serialization must be asked for the + /// renamed form. + /// + /// Unlike [`payload_as`](Self::payload_as) this reads the one field + /// asked for and does not check that the rest of the payload matches the + /// provider's descriptor type. + /// + /// # Examples + /// + /// ``` + /// use serde_json::json; + /// use trusted_server_core::auction::types::BidRenderer; + /// + /// let renderer = BidRenderer::new("example", json!({ "bidId": "fictional-bid-id" })) + /// .expect("should accept an object payload"); + /// + /// assert_eq!( + /// renderer + /// .payload_field("example", "bidId") + /// .and_then(serde_json::Value::as_str), + /// Some("fictional-bid-id"), + /// ); + /// assert!(renderer.payload_field("other", "bidId").is_none()); + /// ``` + #[must_use] + pub fn payload_field(&self, renderer_type: &str, key: &str) -> Option<&serde_json::Value> { + if self.renderer_type != renderer_type { + return None; } + self.payload.get(key) } } @@ -409,6 +514,7 @@ impl AuctionResponse { #[cfg(test)] mod tests { use super::*; + use crate::integrations::aps::{APS_RENDERER_TYPE, ApsRendererV1, ApsTagType}; use serde_json::json; fn make_bid(bidder: &str) -> Bid { @@ -601,17 +707,21 @@ mod tests { #[test] fn aps_renderer_serializes_to_versioned_camel_case_contract() { - let renderer = BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account-id".to_string(), - bid_id: "fictional-bid-id".to_string(), - creative_id: Some("fictional-creative-id".to_string()), - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "base64-data".to_string(), - width: 300, - height: 250, - }); + let renderer = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: Some("fictional-creative-id".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); let serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); @@ -635,23 +745,236 @@ mod tests { #[test] fn aps_renderer_omits_absent_creative_id() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); + + let serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); + + assert!( + serialized.get("creativeId").is_none(), + "should omit absent creative ID" + ); + } + + /// Rewrites every object in `value` with its keys in sorted order, so + /// serializing the result gives one fixed key order. + /// + /// `serde_json::Map` is a `BTreeMap`, which serializes keys in sorted + /// order, only while the crate's `preserve_order` feature is off. With the + /// feature on it is an `IndexMap` and the order follows insertion instead. + /// Nothing in this crate asks for the feature, but Cargo unifies features + /// across everything built for one target, and `trusted-server-cli` pulls + /// it in through `edgezero-cli` and then `handlebars`. A maintainer + /// running `cargo test --workspace --target ` therefore builds this + /// crate with `preserve_order` on, and a test that pinned insertion order + /// would fail there for no reason. Sorting both sides removes the + /// dependence on which map `serde_json` was built with. + fn with_sorted_keys(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(map) => { + let mut keys = map.keys().collect::>(); + keys.sort_unstable(); + let mut sorted = serde_json::Map::with_capacity(keys.len()); + for key in keys { + let child = map.get(key).expect("should find a key the map just listed"); + sorted.insert(key.clone(), with_sorted_keys(child)); + } + serde_json::Value::Object(sorted) + } + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(with_sorted_keys).collect()) + } + scalar => scalar.clone(), + } + } + + #[test] + fn the_open_renderer_serializes_to_the_same_bytes_as_the_aps_variant_did() { + // Literal strings captured from the closed-enum form before this + // change, through the same `serde_json::to_value` path production + // uses: `BidExt::to_ext` for the OpenRTB response extension, and + // `build_bid_map` for `window.tsjs.bids`. + // + // Both sides go through `with_sorted_keys` first, because the key + // order `serde_json` emits is not ours to pin, and that function + // explains why. Sorting settles the order without weakening what is + // pinned, since two objects serialize to the same sorted bytes only + // when they carry exactly the same keys with exactly the same values. + let full = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: Some("fictional-creative-id".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); + let absent = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Script, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); + + let full_bytes = serde_json::to_string(&with_sorted_keys( + &serde_json::to_value(&full).expect("should convert renderer to a JSON value"), + )) + .expect("should serialize renderer"); + let absent_bytes = serde_json::to_string(&with_sorted_keys( + &serde_json::to_value(&absent).expect("should convert renderer to a JSON value"), + )) + .expect("should serialize renderer"); + + assert_eq!( + full_bytes, + "{\"aaxResponse\":\"base64-data\",\"accountId\":\"example-account-id\",\"bidId\":\"fictional-bid-id\",\"creativeId\":\"fictional-creative-id\",\"creativeUrl\":\"https://creative.example/render\",\"height\":250,\"tagType\":\"iframe\",\"type\":\"aps\",\"version\":1,\"width\":300}", + "should serialize to the bytes the closed enum produced" + ); + assert_eq!( + absent_bytes, + "{\"aaxResponse\":\"base64-data\",\"accountId\":\"example-account-id\",\"bidId\":\"fictional-bid-id\",\"creativeUrl\":\"https://creative.example/render\",\"height\":250,\"tagType\":\"script\",\"type\":\"aps\",\"version\":1,\"width\":300}", + "should serialize to the bytes the closed enum produced with no creative ID" + ); + } + + #[test] + fn renderer_round_trips_through_its_wire_form() { + let descriptor = ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), - creative_id: None, + creative_id: Some("fictional-creative-id".to_string()), tag_type: ApsTagType::Iframe, creative_url: "https://creative.example/render".to_string(), aax_response: "base64-data".to_string(), width: 300, height: 250, - }); + }; + let renderer = BidRenderer::from_typed(APS_RENDERER_TYPE, &descriptor) + .expect("should build APS renderer descriptor"); - let serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); + let serialized = serde_json::to_string(&renderer).expect("should serialize renderer"); + let restored: BidRenderer = + serde_json::from_str(&serialized).expect("should deserialize renderer"); + + assert_eq!( + restored.renderer_type(), + APS_RENDERER_TYPE, + "should round-trip the renderer type tag" + ); + assert_eq!( + restored + .payload_as::(APS_RENDERER_TYPE) + .expect("should deserialize the APS payload"), + descriptor, + "should round-trip the provider payload" + ); + } + + #[test] + fn renderer_payload_is_hidden_from_a_different_type_tag() { + let renderer = BidRenderer::new(APS_RENDERER_TYPE, json!({ "version": 1 })) + .expect("should build renderer descriptor"); assert!( - serialized.get("creativeId").is_none(), - "should omit absent creative ID" + renderer.payload_as::("example").is_none(), + "should refuse a payload requested under a different tag" + ); + } + + #[test] + fn renderer_payload_field_borrows_the_same_value_the_whole_descriptor_carries() { + let descriptor = ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: Some("fictional-creative-id".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }; + let renderer = BidRenderer::from_typed(APS_RENDERER_TYPE, &descriptor) + .expect("should build APS renderer descriptor"); + + assert_eq!( + renderer + .payload_field(APS_RENDERER_TYPE, "bidId") + .and_then(serde_json::Value::as_str), + Some(descriptor.bid_id.as_str()), + "should read the same bid id the whole descriptor carries" + ); + assert_eq!( + renderer + .payload_field(APS_RENDERER_TYPE, "bidId") + .and_then(serde_json::Value::as_str), + renderer + .payload_as::(APS_RENDERER_TYPE) + .as_ref() + .map(|full| full.bid_id.as_str()), + "should agree with the field read through the whole descriptor" + ); + assert!( + renderer + .payload_field(APS_RENDERER_TYPE, "notAKey") + .is_none(), + "should return nothing for a key the payload does not carry" + ); + } + + #[test] + fn renderer_payload_field_is_hidden_from_a_different_type_tag() { + let renderer = BidRenderer::new(APS_RENDERER_TYPE, json!({ "bidId": "fictional-bid-id" })) + .expect("should build renderer descriptor"); + + assert!( + renderer.payload_field("example", "bidId").is_none(), + "should refuse a field requested under a different tag" + ); + } + + #[test] + fn renderer_rejects_a_payload_that_is_not_an_object() { + assert!( + BidRenderer::new(APS_RENDERER_TYPE, json!("not-an-object")).is_err(), + "should reject a payload that is not a JSON object" + ); + } + + #[test] + fn renderer_rejects_a_payload_carrying_its_own_type_key() { + assert!( + BidRenderer::new(APS_RENDERER_TYPE, json!({ "type": "other", "version": 1 })).is_err(), + "should reject a payload that would collide with the type tag" ); } diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 3e6fedc0a..5faac0fb2 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -1,15 +1,16 @@ //! Auction configuration types shared by settings and auction planning. -use serde::de::{Error as _, MapAccess, SeqAccess, Visitor, value::MapAccessDeserializer}; +use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::{BTreeMap, HashSet}; -use std::fmt; use validator::Validate; -const LEGACY_PROVIDER_LIST_MESSAGE: &str = "Configuration field `auction.providers` uses the removed list schema; migrate to `[auction.providers.]` map entries as described in the CHANGELOG.md breaking migration"; +const MOVED_PROVIDERS_MESSAGE: &str = "`[auction.providers]` has moved. Select demand sources with `[demand] provider = [...]` and give each its settings in `[demand.]`, as the configuration rules describe"; + +const MOVED_MEDIATOR_MESSAGE: &str = "`[auction] mediator` has moved. Select the ad server with `[adserver] provider = \"\"` and give it its settings in `[adserver.]`, as the configuration rules describe"; pub use crate::auction::plan::{ - BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, + BidderId, BidderRouteConfig, NotificationConfig, ProviderId, RoutingMode, }; /// Auction orchestration configuration. @@ -49,18 +50,45 @@ pub struct AuctionConfig { )] pub rewrite_creatives: bool, - /// Operator-defined bidder-provider instances, keyed by provider ID. - #[serde(default, deserialize_with = "deserialize_provider_map")] - pub providers: BTreeMap, + /// Refuses the removed `providers` table with a message naming its new + /// home, so an old configuration fails with the fix rather than as an + /// unknown field. + #[serde(default, skip_serializing, deserialize_with = "reject_moved_providers")] + #[allow( + dead_code, + reason = "the field exists so deserialization refuses the removed table" + )] + pub(crate) providers: MovedSetting, - /// Client-visible bidder routes, keyed by bidder code. + /// Client-visible bidder routes, keyed by bidder code, each naming the + /// `[demand]` provider the bidder is sent to. #[serde(default)] pub bidders: BTreeMap, - /// Optional separately registered mediator provider name. - /// When set, runs parallel mediation strategy (bidders in parallel, then mediator decides) - /// When omitted, runs parallel only strategy (bidders in parallel, highest CPM wins) - pub mediator: Option, + /// The ad server name the legacy test orchestrator runs. + /// + /// Production selects its ad server in `[adserver]` instead, so this never + /// appears in a configuration file. + #[cfg(test)] + #[serde(skip)] + pub(crate) adserver_name: Option, + + /// The demand source names the legacy test orchestrator runs. + /// + /// Production compiles its sources from `[demand]` instead, so this never + /// appears in a configuration file. + #[cfg(test)] + #[serde(skip)] + pub(crate) provider_names: Vec, + + /// Refuses the removed `mediator` setting with a message naming its new + /// home. + #[serde(default, skip_serializing, deserialize_with = "reject_moved_mediator")] + #[allow( + dead_code, + reason = "the field exists so deserialization refuses the removed setting" + )] + pub(crate) mediator: MovedSetting, /// Timeout in milliseconds #[serde(default = "default_timeout")] @@ -85,9 +113,13 @@ impl Default for AuctionConfig { enabled: false, sanitize_creatives: default_sanitize_creatives(), rewrite_creatives: default_rewrite_creatives(), - providers: BTreeMap::new(), + providers: MovedSetting, + #[cfg(test)] + adserver_name: None, + #[cfg(test)] + provider_names: Vec::new(), bidders: BTreeMap::new(), - mediator: None, + mediator: MovedSetting, timeout_ms: default_timeout(), creative_store: default_creative_store(), allowed_context_keys: HashSet::new(), @@ -95,37 +127,32 @@ impl Default for AuctionConfig { } } -fn deserialize_provider_map<'de, D>( - deserializer: D, -) -> Result, D::Error> +#[cfg(test)] +impl AuctionConfig { + /// Whether the legacy test orchestrator runs an ad server. + pub(crate) fn has_adserver(&self) -> bool { + self.adserver_name.is_some() + } +} + +/// A setting that has moved elsewhere. It deserializes only by failing. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct MovedSetting; + +fn reject_moved_providers<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { - struct ProviderMapVisitor; - - impl<'de> Visitor<'de> for ProviderMapVisitor { - type Value = BTreeMap; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a map of auction provider IDs to provider configurations") - } - - fn visit_map(self, map: A) -> Result - where - A: MapAccess<'de>, - { - Self::Value::deserialize(MapAccessDeserializer::new(map)) - } - - fn visit_seq(self, _sequence: A) -> Result - where - A: SeqAccess<'de>, - { - Err(A::Error::custom(LEGACY_PROVIDER_LIST_MESSAGE)) - } - } + let _ = serde::de::IgnoredAny::deserialize(deserializer)?; + Err(D::Error::custom(MOVED_PROVIDERS_MESSAGE)) +} - deserializer.deserialize_any(ProviderMapVisitor) +fn reject_moved_mediator<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let _ = serde::de::IgnoredAny::deserialize(deserializer)?; + Err(D::Error::custom(MOVED_MEDIATOR_MESSAGE)) } fn default_timeout() -> u32 { @@ -157,36 +184,6 @@ fn default_allowed_context_keys() -> HashSet { HashSet::new() } -impl AuctionConfig { - #[cfg(test)] - pub(crate) fn legacy_provider_map(names: &[&str]) -> BTreeMap { - names - .iter() - .map(|name| { - let id = ProviderId::unchecked_for_legacy_test(name); - ( - id, - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "standard".to_string(), - endpoint: format!("https://{name}.example/openrtb2/auction"), - timeout_ms: None, - routing: RoutingMode::AllEligible, - notifications: NotificationConfig::default(), - profile_config: serde_json::json!({}), - }, - ) - }) - .collect() - } - - /// Check if this config has a mediator configured. - #[must_use] - pub fn has_mediator(&self) -> bool { - self.mediator.is_some() - } -} - #[cfg(test)] mod tests { use super::*; @@ -281,35 +278,50 @@ mod tests { } #[test] - fn provider_list_shape_is_rejected() { + fn moved_providers_fail_naming_the_demand_table() { + for providers in [ + serde_json::json!(["prebid"]), + serde_json::json!({ "pbs_main": { "endpoint": "https://prebid.example" } }), + ] { + let error = serde_json::from_value::(serde_json::json!({ + "providers": providers + })) + .expect_err("should refuse the moved providers table"); + + assert!( + error.to_string().contains("[demand] provider"), + "should name the new home: {error}" + ); + } + } + + #[test] + fn moved_mediator_fails_naming_the_adserver_table() { let error = serde_json::from_value::(serde_json::json!({ - "providers": ["prebid"] + "mediator": "adserver_mock" })) - .expect_err("should reject the removed provider-list schema"); + .expect_err("should refuse the moved mediator setting"); assert!( - error.to_string().contains("map") || error.to_string().contains("object"), - "should require map-shaped providers: {error}" + error.to_string().contains("[adserver] provider"), + "should name the new home: {error}" ); } #[test] - fn map_schema_round_trips_provider_and_bidder_routes() { + fn bidder_routes_round_trip() { let config: AuctionConfig = serde_json::from_value(serde_json::json!({ - "providers": { - "pbs-main": { - "protocol": "openrtb-2.6", - "profile": "prebid-server", - "endpoint": "https://prebid.example/openrtb2/auction" - } - }, "bidders": { - "example-bidder": { "provider": "pbs-main" } + "example-bidder": { "provider": "pbs_main" } } })) - .expect("should parse map-shaped auction config"); + .expect("should parse bidder routes"); - assert_eq!(config.providers.len(), 1); assert_eq!(config.bidders.len(), 1); + let serialized = serde_json::to_value(&config).expect("should serialize"); + assert!( + serialized.get("providers").is_none() && serialized.get("mediator").is_none(), + "should never write the moved settings back" + ); } } diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index f12b5723f..134b919d9 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -11,48 +11,17 @@ use std::borrow::Cow; use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; +use crate::ec::provider::{HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY}; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; -use crate::integrations::{ - adserver_mock::AdServerMockConfig, - aps::ApsConfig, - datadome::DataDomeConfig, - didomi::DidomiIntegrationConfig, - google_tag_manager::GoogleTagManagerConfig, - gpt::GptConfig, - gpt_diagnostics::GptDiagnosticsConfig, - js_asset_proxy::{JS_ASSET_PROXY_INTEGRATION_ID, JsAssetProxyConfig}, - lockr::LockrConfig, - nextjs::NextJsIntegrationConfig, - osano::OsanoConfig, - permutive::PermutiveConfig, - prebid, - sourcepoint::SourcepointConfig, - testlight::TestlightConfig, -}; -use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; + +use crate::integrations::datadome::DataDomeConfig; +use crate::integrations::{IntegrationBuilder, prebid}; +use crate::settings::{AssetOriginAuth, Ec, PROVIDER_IMPLEMENTATION_KEY, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; -#[cfg(test)] -const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ - "prebid", - "aps", - "adserver_mock", - "testlight", - "nextjs", - "permutive", - "lockr", - "didomi", - "sourcepoint", - "osano", - "google_tag_manager", - "datadome", - "gpt", - "gpt_diagnostics", - JS_ASSET_PROXY_INTEGRATION_ID, -]; /// Typed app-config root used by the `ts` CLI. /// @@ -117,6 +86,7 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = self.settings.validate().err().unwrap_or_default(); + remove_labeled_provider_secret_errors(&mut errors, &self.settings.ec); if let Err(report) = validate_settings_for_deploy(&self.settings) { errors.add( DEPLOY_VALIDATION_FIELD, @@ -131,6 +101,100 @@ impl Validate for TrustedServerAppConfig { } } +/// Removes the passphrase checks on Edge Cookie provider blocks written under +/// a label. +/// +/// Push-time validation reads a configuration whose secret fields hold +/// secret-store key names rather than the secrets themselves, so a value check +/// such as the 32-byte passphrase minimum would be judging a key name. +/// `EdgeZero`'s `validate_excluding_secrets` removes those checks for the +/// leaves [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) +/// lists, which covers the `[ec.hmac]` block. A block under a label of the +/// operator's choosing has no fixed path that list can hold, so its check is +/// removed here instead. The check itself is unchanged, and runs wherever +/// settings are loaded with their secrets resolved. +fn remove_labeled_provider_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { + let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { + return; + }; + let labeled = ec + .provider_blocks + .hmac_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HMAC_PROVIDER_KEY) + .chain( + ec.provider_blocks + .host_signals_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HOST_SIGNALS_PROVIDER_KEY), + ); + for name in labeled { + let Some(ValidationErrorsKind::Struct(block_errors)) = ec_errors.errors_mut().get_mut(name) + else { + continue; + }; + block_errors.errors_mut().remove("passphrase"); + if block_errors.errors().is_empty() { + ec_errors.errors_mut().remove(name); + } + } + // An `ec` entry holding nothing would keep the whole result an error, the + // same reason `EdgeZero` prunes emptied containers after its own removals. + let ec_is_empty = ec_errors.errors().is_empty(); + if ec_is_empty { + errors.errors_mut().remove("ec"); + } +} + +impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig { + /// The passphrase of every Edge Cookie provider block that configures a + /// provider built into core under a label. + /// + /// [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) + /// lists the passphrases of the `[ec.hmac]` and `[ec.host_signals]` + /// blocks, the one path each of those providers' blocks has when its name + /// is its implementation. The same provider under a label of the + /// operator's choosing holds that secret at `ec.(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut blocks = BTreeMap::new(); + while let Some(name) = map.next_key::()? { + let table = map.next_value::()?; + blocks.insert(name.clone(), read_provider_block::(&name, table)?); + } + Ok(EcProviderBlocks(blocks)) + } + } + + deserializer.deserialize_map(BlocksVisitor) + } +} + +/// Reads one key left over from the `[ec]` section as the provider block it +/// names. +/// +/// This is where the `[ec]` section gets the unknown-key check that the fixed +/// fields lose by holding the provider blocks alongside them. A key that is +/// not a table cannot be a provider, so it is reported as the mistyped setting +/// it almost certainly is. +fn read_provider_block(name: &str, table: JsonValue) -> Result +where + E: serde::de::Error, +{ + if name == REMOVED_EC_PROVIDERS_TABLE { + return Err(E::custom( + "[ec.providers] is no longer read. Each provider's settings moved to a table of \ + its own under [ec], so a block written as [ec.providers.hmac] is now [ec.hmac]", + )); + } + let JsonValue::Object(mut table) = table else { + let known = EC_SECTION_KEYS + .iter() + .map(|key| format!("`{key}`")) + .collect::>() + .join(", "); + return Err(E::custom(format!( + "unknown field `{name}`, expected one of {known}, or an [ec.] provider \ + settings table" + ))); + }; + let implementation = match table.remove(PROVIDER_IMPLEMENTATION_KEY) { + None => None, + Some(JsonValue::String(implementation)) => Some(implementation), + Some(_) => { + return Err(E::custom(format!( + "`implementation` in [ec.{name}] must be a string naming the provider \ + implementation the block configures" + ))); + } + }; + + // The implementation decides how the rest of the table is read. Core reads + // its own providers' settings here, so a mistyped key fails where the + // configuration is read rather than at the request that needed it, and + // keeps the settings of a provider an adapter injects as the raw values + // that adapter deserializes for itself. + let invalid = |err| E::custom(format!("[ec.{name}] is invalid ({err})")); + let settings = match implementation.as_deref().unwrap_or(name) { + HMAC_PROVIDER_KEY => EcProviderSettings::Hmac( + serde_json::from_value(JsonValue::Object(table)).map_err(invalid)?, + ), + HOST_SIGNALS_PROVIDER_KEY => EcProviderSettings::HostSignals( + serde_json::from_value(JsonValue::Object(table)).map_err(invalid)?, + ), + _ => EcProviderSettings::Injected(table), + }; + Ok(EcProviderBlock { + implementation, + settings, + }) +} + +/// One provider's `[ec.]` settings table. +#[derive(Debug, Clone, Serialize)] +pub struct EcProviderBlock { + /// The implementation this block configures, written as + /// `implementation = ""`, when the block's own name is not it. + /// + /// Setting it makes the block name a label of the operator's choosing, so + /// one implementation can be configured under any name. Everything that + /// resolves the selected provider reads the implementation rather than the + /// label. + #[serde(skip_serializing_if = "Option::is_none")] + pub implementation: Option, + + /// Everything the block holds other than `implementation`. + #[serde(flatten)] + pub settings: EcProviderSettings, +} + +impl EcProviderBlock { + /// The built-in HMAC provider's settings, when this block configures it. + #[must_use] + pub fn hmac_settings(&self) -> Option<&HmacProviderConfig> { + match &self.settings { + EcProviderSettings::Hmac(config) => Some(config), + EcProviderSettings::HostSignals(_) | EcProviderSettings::Injected(_) => None, + } + } + + /// The built-in host-signal provider's settings, when this block + /// configures it. + #[must_use] + pub fn host_signals_settings(&self) -> Option<&HostSignalsProviderConfig> { + match &self.settings { + EcProviderSettings::HostSignals(config) => Some(config), + EcProviderSettings::Hmac(_) | EcProviderSettings::Injected(_) => None, + } + } +} + +impl From for EcProviderBlock { + /// Builds the `[ec.hmac]` block, the built-in HMAC provider configured + /// under its own name. A block under a label names its implementation + /// instead. + fn from(config: HmacProviderConfig) -> Self { + Self { + implementation: None, + settings: EcProviderSettings::Hmac(config), + } + } +} + +impl From for EcProviderBlock { + /// Builds the `[ec.host_signals]` block, the built-in host-signal provider + /// configured under its own name. A block under a label names its + /// implementation instead. + fn from(config: HostSignalsProviderConfig) -> Self { + Self { + implementation: None, + settings: EcProviderSettings::HostSignals(config), + } + } +} + +/// The settings one provider block holds, read according to the +/// implementation the block configures. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum EcProviderSettings { + /// The built-in HMAC provider's settings. + Hmac(HmacProviderConfig), + + /// The built-in host-signal provider's settings. + HostSignals(HostSignalsProviderConfig), + + /// The settings of a provider an adapter injects, kept as the raw values + /// the block held. The adapter that builds the provider deserializes them + /// into the vendor crate's own config type, so core never names a vendor + /// and a new provider adds nothing here. + Injected(serde_json::Map), +} + +/// Configuration for the built-in HMAC Edge Cookie provider. +/// +/// Mapped from the `[ec.hmac]` TOML block, or from a block under a label whose +/// `implementation` is `hmac`. Unknown keys are rejected, so a mistyped +/// setting fails at startup instead of being accepted silently and leaving the +/// intended setting at its default. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct HmacProviderConfig { + /// Publisher passphrase used as the HMAC key for EC generation. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, +} + +/// Configuration for the built-in host-signal Edge Cookie provider. +/// +/// Mapped from the `[ec.host_signals]` TOML block, or from a block under a +/// label whose `implementation` is `host_signals`. Unknown keys are rejected, +/// so a mistyped setting fails at startup instead of being accepted silently +/// and leaving the intended setting at its default. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct HostSignalsProviderConfig { + /// Passphrase used as the HMAC key over the host signals and client IP. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, +} + +/// Device-detection configuration. +/// +/// Mapped from the `[device]` TOML section. Selects which device-detection +/// provider classifies a request into device signals, mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct DeviceConfig { + /// The key of the device-detection provider to activate. + /// + /// Defaults to the built-in `builtin` provider when absent, which classifies + /// from the User-Agent alone, so device classification itself makes no + /// host-specific call. The opt-in `fastly` provider strengthens the + /// browser/bot gate with the host's TLS and HTTP/2 signals, which the Fastly + /// entry point reads on every request regardless of this selector. Override + /// it with the + /// `TRUSTED_SERVER__device__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. An unknown key is + /// rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, +} + +impl DeviceConfig { + /// Returns the active device-detection provider key, defaulting to the + /// built-in heuristic. + #[must_use] + pub fn provider_key(&self) -> &str { + self.provider.as_deref().unwrap_or("builtin") + } + + /// Validates the selected device-detection provider. + /// + /// `builtin` and `fastly` are resolved by the core and the Fastly adapter. + /// Any other key names an integration module that declares a device + /// provider, and the registry rejects a key no module supplies, so this + /// cannot be a closed list without shutting modules out of device detection + /// entirely. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] never, today. The error + /// type is kept because the caller treats provider validation uniformly + /// across the three capabilities. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + Ok(()) + } +} + +/// Which permission signal providers run, and in what order. +/// +/// Mapped from the `[permission_signal]` TOML section, where `provider` +/// selects, as it does in `[ec]`, `[geo]` and `[device]`. Those each name one +/// provider, whereas signals compose, because a request can carry a TCF string +/// and a Global Privacy Control header at once and both have something to say. +/// So here `provider` names a list, and the order is the policy, because the +/// last provider with an opinion decides. +/// +/// A provider that gains settings will take them in a +/// `[permission_signal.]` block named for it. None of the providers that +/// ship has settings, so `provider` is the only key accepted, and any other key +/// is refused as an unknown field rather than silently ignored. +/// +/// See `crates/trusted-server-core/src/permission_signal/README.md`. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Validate)] +pub struct PermissionSignalConfig { + /// The providers to run, in order, named by the identifier each provider + /// crate declares, for example `gpc`, `gpp_sale_opt_out`, `us_privacy` and + /// `tcf` for the four that ship. + /// + /// Absent means every provider the adapter offers, in the order it offers + /// them. A publisher who does not want to act on one removes it from the + /// list, and there is no separate switch, because a provider that is not + /// listed does not run. An empty list runs none of them, leaving every + /// permission at its country and region baseline. + /// + /// Which names are valid is only known where the provider crates are + /// linked, so the check that each name matches an available provider and + /// none is repeated happens at the adapter's composition root, through + /// [`build_permission_signal_providers`], and refuses startup rather than + /// silently ignoring a typo. + /// + /// [`build_permission_signal_providers`]: + /// crate::permission_signal::build_permission_signal_providers + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option>, +} + +/// Read by hand rather than derived, so that `sources`, the key `provider` +/// replaced, is refused with a message saying what to write instead. A derived +/// struct could only refuse it by name by declaring it as a field, and would +/// then list it among the keys it expects whenever it refused any other. +impl<'de> Deserialize<'de> for PermissionSignalConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut section = serde_json::Map::::deserialize(deserializer)?; + if section.contains_key("sources") { + return Err(serde::de::Error::custom( + "[permission_signal] sources is no longer accepted. Name the providers \ + to run, in order, in [permission_signal] provider instead", + )); + } + if let Some(key) = section.keys().find(|key| key.as_str() != "provider") { + return Err(serde::de::Error::custom(format!( + "unknown field `{key}` in [permission_signal], expected `provider`. No \ + permission signal provider takes settings yet, so a \ + [permission_signal.] block is not accepted" + ))); + } + // Read as an option, so an explicit JSON null is the same as leaving + // the key out. + let provider = match section.remove("provider") { + Some(value) => serde_json::from_value(value).map_err(serde::de::Error::custom)?, + None => None, + }; + Ok(Self { provider }) + } +} + +/// Geo / IP intelligence configuration. +/// +/// Mapped from the `[geo]` TOML section. Selects which provider resolves a +/// client IP into [`GeoInfo`](crate::platform::GeoInfo), mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct GeoConfig { + /// The key of the geo provider to activate. + /// + /// No provider is the default: Trusted Server resolves no geolocation and + /// makes no host geo call, so a default deployment is not tied to any host + /// geo service, and the permission baseline comes from the top of the + /// `rules` tree in `permissions.yaml`. `provider = "none"` spells + /// the same choice explicitly. The host platform's own geo lookup is + /// opt-in via `provider = "platform"`. Override it with the + /// `TRUSTED_SERVER__geo__provider` environment variable so the same compiled + /// WebAssembly can switch providers at deployment. An unknown key is rejected + /// at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + + /// Acknowledges that, with no geo provider, every request is treated as + /// coming from the place at the top of the `permissions.yaml` `rules` tree. + /// + /// With geolocation off, a visitor from any other jurisdiction silently + /// receives that top node's permission rules. A deployment that + /// runs an Edge Cookie provider without a geo provider must set this to + /// `true`, checked at startup by + /// [`validate_jurisdiction_acknowledgment`](Self::validate_jurisdiction_acknowledgment), + /// so serving a single jurisdiction is an explicit operator decision rather + /// than an accident of the default configuration. + #[serde(default)] + pub assume_single_jurisdiction: bool, +} + +impl GeoConfig { + /// Validates that the selected geo provider is available in this build. + /// + /// No selector is valid and is the default, running without geolocation, + /// the same way the Edge Cookie provider runs statelessly when none is + /// selected. The explicit `"none"` spells the same choice. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is not one this build provides. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + // Unset, `none` and `platform` are resolved by the core. Any other key + // names an integration module that declares a geo provider, and the + // registry rejects one that no module supplies, so this check cannot be + // a closed list without shutting modules out of geo entirely. + Ok(()) + } + + /// Validates that the compiled `permissions.yaml` parses and declares its + /// top node. + /// + /// The top node is required: its `group` is the permission baseline for a + /// request the geo provider leaves unmatched, and its `jurisdiction` is the + /// consent handling for that same request, so there must always be one. + /// Checking it here turns a malformed policy into a configuration error at + /// startup rather than a panic on the first lookup. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the compiled policy + /// fails to parse, most usefully when its top node omits `group` or + /// `jurisdiction`. + pub fn validate_permission_policy() -> Result<(), Report> { + crate::permissions::validate_default_policy().map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("permissions.yaml is not usable: {error}"), + }) + }) + } + + /// Validates that running jurisdiction consumers without geolocation is + /// explicitly acknowledged. + /// + /// With no geo provider, every request resolves to the permission baseline + /// at the top of the `permissions.yaml` `rules` tree, so a visitor from any + /// other jurisdiction silently receives that node's rules. That is + /// acceptable only as an explicit operator + /// decision. When an Edge Cookie provider is configured (the permission + /// model gates it by jurisdiction) and no geo provider is selected, + /// [`assume_single_jurisdiction`](Self::assume_single_jurisdiction) must be + /// `true`. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when an Edge Cookie + /// provider is configured, no geo provider is selected, and + /// `assume_single_jurisdiction` is not set. + pub fn validate_jurisdiction_acknowledgment( + &self, + ec: &Ec, + ) -> Result<(), Report> { + // Location is resolved by the host lookup (`platform`) or by any + // integration module that declares a geo provider. Only an unset + // selector and the explicit `none` resolve nothing, so only those + // two leave every request on the default country. + let geo_disabled = matches!(self.provider.as_deref(), None | Some("none")); + // The selector is a typed enum on this branch, so statelessness is the + // absent selector or the explicit `none`, matched rather than compared + // as a string. + let ec_active = !matches!(ec.provider, None | Some(EcProviderSelection::None)); + if geo_disabled && ec_active && !self.assume_single_jurisdiction { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider is configured but no [geo] provider is selected, so \ + every request would be treated as the top of the permissions.yaml \ + rules tree. Set [geo] assume_single_jurisdiction = true to \ + acknowledge single-jurisdiction operation, or select a geo provider" + .to_owned(), + })); + } + Ok(()) + } } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -1723,7 +2713,7 @@ pub struct Proxy { /// /// When empty (the default), proxy hosts are not restricted. Configure this /// in production to constrain signed and fetched first-party proxy targets. - /// When `integrations.prebid.external_bundle_url` is configured, this list + /// When `integration.prebid.external_bundle_url` is configured, this list /// must include its host and any HTTPS redirect targets. #[serde(default, deserialize_with = "vec_from_seq_or_map")] pub allowed_domains: Vec, @@ -2592,6 +3582,57 @@ fn is_default_auction_debug_comment_options(value: &AuctionDebugCommentOptions) *value == AuctionDebugCommentOptions::default() } +// The provider selectors are new sections, so a serialized blob that carries +// them is rejected by a base-revision binary that has never heard of them. +// Omitting the default table keeps an unchanged `ts config push` readable +// across a rollout or a rollback. +fn is_default_device_config(value: &DeviceConfig) -> bool { + *value == DeviceConfig::default() +} + +fn is_default_geo_config(value: &GeoConfig) -> bool { + *value == GeoConfig::default() +} + +fn is_default_permission_signal_config(value: &PermissionSignalConfig) -> bool { + *value == PermissionSignalConfig::default() +} + +// `[integration]` is a new section under this name, so a serialized blob that +// carries it is rejected by a base-revision binary that has never heard of it. +// An unconfigured section is omitted for the same reason the selector tables +// above are. +fn is_default_integration_config(value: &IntegrationSettings) -> bool { + value.provider.is_empty() && value.entries.is_empty() +} + +/// Message a configuration still carrying the removed `[integrations]` table +/// is rejected with. +const REMOVED_INTEGRATIONS_TABLE_MESSAGE: &str = "Configuration table `[integrations]` was removed. Move each `[integrations.]` block to \ + `[integration.]`, name the integrations that run in `[integration] provider`, and delete \ + every `enabled` key, as described in the CHANGELOG.md breaking migration"; + +/// The removed `[integrations]` table. +/// +/// Reading one always fails, with [`REMOVED_INTEGRATIONS_TABLE_MESSAGE`], so +/// the value is never held and the type carries no data. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct RemovedIntegrationsTable; + +impl<'de> Deserialize<'de> for RemovedIntegrationsTable { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // The value is read and discarded first so that a table, a string or + // anything else all reach the same message. Reporting a type error + // instead would send an operator looking for a type problem in a table + // that has simply moved. + serde::de::IgnoredAny::deserialize(deserializer)?; + Err(serde::de::Error::custom(REMOVED_INTEGRATIONS_TABLE_MESSAGE)) + } +} + /// Behavior of the `` auction dump. Only consulted when /// [`DebugConfig::auction_html_comment`] is true. /// @@ -2606,9 +3647,9 @@ pub struct AuctionDebugCommentOptions { #[serde(default = "default_true")] pub include_provider_responses: bool, - /// Include `mediator_response` when a mediator ran. + /// Include `adserver_response` when an ad server ran. #[serde(default = "default_true")] - pub include_mediator_response: bool, + pub include_adserver_response: bool, /// Include each provider's `bids` array (vs. status/metadata only). #[serde(default = "default_true")] @@ -2650,7 +3691,7 @@ impl Default for AuctionDebugCommentOptions { fn default() -> Self { Self { include_provider_responses: true, - include_mediator_response: true, + include_adserver_response: true, include_bids: true, metadata_keys: default_auction_debug_metadata_keys(), verbosity: AuctionDebugCommentVerbosity::Redacted, @@ -2860,8 +3901,20 @@ pub struct Settings { #[serde(default)] #[validate(nested)] pub ec: Ec, - #[serde(default)] - pub integrations: IntegrationSettings, + /// The removed `[integrations]` table. + /// + /// The name is kept so a configuration written for the previous release is + /// told where its blocks moved, rather than being handed a bare + /// unknown-field error listing every table Trusted Server accepts. The + /// field never holds a value, because reading one always fails. + #[serde(default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the removed table fails with directions" + )] + integrations: RemovedIntegrationsTable, + #[serde(default, skip_serializing_if = "is_default_integration_config")] + pub integration: IntegrationSettings, #[serde(default, deserialize_with = "vec_from_seq_or_map")] #[validate(nested)] pub handlers: Vec, @@ -2874,6 +3927,14 @@ pub struct Settings { #[serde(default)] #[validate(nested)] pub auction: AuctionConfig, + /// The auction's demand sources. `[demand] provider` selects them and each + /// `[demand.]` table holds one source's settings. + #[serde(default, skip_serializing_if = "ProviderList::is_unset")] + pub demand: ProviderList, + /// The ad server that picks the auction winner. `[adserver] provider` + /// selects it and `[adserver.]` holds its settings. + #[serde(default, skip_serializing_if = "ProviderChoice::is_unset")] + pub adserver: ProviderChoice, #[serde(default)] pub consent: ConsentConfig, #[serde(default)] @@ -2888,6 +3949,15 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + #[serde(default, skip_serializing_if = "is_default_device_config")] + #[validate(nested)] + pub device: DeviceConfig, + #[serde(default, skip_serializing_if = "is_default_geo_config")] + #[validate(nested)] + pub geo: GeoConfig, + #[serde(default, skip_serializing_if = "is_default_permission_signal_config")] + #[validate(nested)] + pub permission_signal: PermissionSignalConfig, } impl Settings { @@ -2963,7 +4033,7 @@ impl Settings { self.image_optimizer.normalize(); self.debug.auction_html_comment_options.normalize(); self.tinybird.normalize(); - self.integrations + self.integration .remove_legacy_static_secret_store_selectors(); self.consent.validate(); } @@ -2984,9 +4054,34 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_ec_layout()?; + settings.ec.validate_provider_selection()?; + settings.integration.validate_selection()?; + settings.device.validate_provider_selection()?; + settings.geo.validate_provider_selection()?; + GeoConfig::validate_permission_policy()?; + settings + .geo + .validate_jurisdiction_acknowledgment(&settings.ec)?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; + // Log the policy's declared default once per settings load, so an + // operator can see which permissions an unmatched request is granted + // without a signal, and which jurisdiction its consent gates apply. + let maps = crate::permissions::PermissionMaps::standard(); + let granted: Vec = maps + .baseline(None, None) + .permissions() + .iter() + .map(|permission| permission.to_string()) + .collect(); + log::debug!( + "Permission baseline: permissions.yaml top node, jurisdiction {}; granted without a signal: [{}]", + maps.default_jurisdiction(), + granted.join(", ") + ); + Ok(settings) } @@ -3068,8 +4163,15 @@ impl Settings { pub fn reject_placeholder_secrets(&self) -> Result<(), Report> { let mut insecure_fields: Vec = Vec::new(); - if Ec::is_placeholder_passphrase(self.ec.passphrase.expose()) { - insecure_fields.push("ec.passphrase".to_owned()); + for (name, hmac) in self.ec.provider_blocks.hmac_blocks() { + if Ec::is_placeholder_passphrase(hmac.passphrase.expose()) { + insecure_fields.push(format!("ec.{name}.passphrase")); + } + } + for (name, host_signals) in self.ec.provider_blocks.host_signals_blocks() { + if Ec::is_placeholder_passphrase(host_signals.passphrase.expose()) { + insecure_fields.push(format!("ec.{name}.passphrase")); + } } if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); @@ -3326,7 +4428,11 @@ impl Settings { Ok(()) } - /// Retrieves the integration configuration of a specific type. + /// Retrieves a selected integration's configuration of a specific type. + /// + /// Hands back `None` when `[integration] provider` does not name the + /// integration, so a caller that reads its own configuration is also + /// asking whether it runs. /// /// # Errors /// @@ -3338,7 +4444,7 @@ impl Settings { where T: IntegrationConfig, { - self.integrations.get_typed(integration_id) + self.integration.get_typed(integration_id) } } @@ -3685,13 +4791,14 @@ mod tests { use std::collections::HashSet; use std::sync::Arc; - use crate::auction::build_orchestrator; use crate::integrations::{ - IntegrationRegistry, gpt::GptConfig, nextjs::NextJsIntegrationConfig, - prebid::PrebidIntegrationConfig, + IntegrationRegistry, nextjs::NextJsIntegrationConfig, prebid::PrebidIntegrationConfig, }; use crate::redacted::Redacted; - use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + use crate::test_support::tests::{ + crate_test_settings_str, crate_test_settings_str_with_ec_section, create_test_settings, + hmac_passphrase, select_hmac_provider, + }; fn trusted_client_ip_toml(ip_header: &str, auth_header: &str, shared_secret: &str) -> String { format!( @@ -3772,15 +4879,78 @@ mod tests { #[test] fn serialized_default_config_stays_readable_by_the_base_revision_schema() { - let settings = Settings::from_toml(&crate_test_settings_str()) + let mut settings = Settings::from_toml(&crate_test_settings_str()) .expect("should parse settings without trusted client IP configuration"); + // The guarantee covers a config that configures none of the sections + // added since the base revision. A configured section is serialized and, + // like a configured `trusted_client_ip`, needs a compatible blob restored + // before rolling back to a binary that predates it. The shared test + // config sets `[geo]` because the permission model requires a default + // country, so both selector tables are reset to unset here. + settings.geo = GeoConfig::default(); + settings.device = DeviceConfig::default(); + settings.integration = IntegrationSettings::default(); + let value = serde_json::to_value(&settings).expect("should serialize settings"); serde_json::from_value::(value) .expect("base revision schema should accept a config blob with no trusted client IP"); } + #[test] + fn geo_selector_is_omitted_from_serialized_config_when_unset() { + // `ts config push` serializes `Settings` verbatim, so a selector nobody + // set must not appear in the blob. A `deny_unknown_fields` binary that + // predates the selector rejects the key during rollout or rollback. + // + // The shared fixture writes a `[geo]` table (it acknowledges running + // with no geo provider), so the assertion is about the selector key + // rather than the table, which is what the blob's compatibility + // actually turns on. + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without a geo selector"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + let geo = value + .get("geo") + .expect("the geo table is written because the fixture acknowledges no geo provider"); + assert!( + geo.get("provider").is_none(), + "an unset geo selector should not be serialized, got {geo}" + ); + assert!( + value + .get("device") + .is_none_or(|device| device.get("provider").is_none()), + "an unset device selector should not be serialized, got {value}" + ); + } + + #[test] + fn a_selected_geo_provider_stays_in_the_serialized_config() { + // The shared test settings already carry a `[geo]` table, so the + // selector is set inside that table rather than in a second one, which + // TOML rejects as a duplicate key. + let settings = Settings::from_toml(&crate_test_settings_str().replace( + "[geo]", + "[geo] +provider = \"none\"", + )) + .expect("should parse settings with a geo selector"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + assert_eq!( + value + .pointer("/geo/provider") + .and_then(serde_json::Value::as_str), + Some("none"), + "a selected geo provider should survive serialization" + ); + } + #[test] fn trusted_client_ip_parses_and_redacts_shared_secret_in_debug_output() { let settings = Settings::from_toml(&trusted_client_ip_toml( @@ -4222,8 +5392,8 @@ mod tests { "error should identify the removed field, got {rendered}" ); assert!( - rendered.contains("CHANGELOG.md"), - "error should direct operators to the migration guidance, got {rendered}" + rendered.contains("[demand] provider"), + "error should name where the setting moved to, got {rendered}" ); } @@ -4242,8 +5412,8 @@ mod tests { "error should identify the removed field, got {rendered}" ); assert!( - rendered.contains("CHANGELOG.md"), - "error should direct operators to the migration guidance, got {rendered}" + rendered.contains("[demand] provider"), + "error should name where the setting moved to, got {rendered}" ); } @@ -4251,7 +5421,7 @@ mod tests { fn auction_debug_comment_options_default_matches_serde_defaults() { let opts = AuctionDebugCommentOptions::default(); assert!(opts.include_provider_responses, "should default to true"); - assert!(opts.include_mediator_response, "should default to true"); + assert!(opts.include_adserver_response, "should default to true"); assert!(opts.include_bids, "should default to true"); assert_eq!( opts.metadata_keys, @@ -4487,17 +5657,12 @@ mod tests { .integration_config::("nextjs") .expect("Next.js config query should succeed") .is_none(), - "Next.js integration should default to disabled" + "an integration the provider list does not name should not run" ); - let raw_nextjs = settings - .integrations - .get("nextjs") - .expect("test settings should include nextjs block"); - assert_eq!(raw_nextjs["enabled"], json!(false)); assert_eq!( - raw_nextjs["rewrite_attributes"], - json!(["href", "link", "url"]), - "Next.js rewrite attributes should default to href/link/url" + settings.integration.provider, + vec!["prebid".to_owned()], + "the fixture should run exactly the integration it names" ); assert_eq!(settings.publisher.domain, "test-publisher.com"); assert_eq!(settings.publisher.cookie_domain, ".test-publisher.com"); @@ -4516,7 +5681,12 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.passphrase.expose(), + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), + "test settings should select the hmac EC provider" + ); + assert_eq!( + hmac_passphrase(&settings.ec, HMAC_PROVIDER_KEY), "test-secret-key-32-bytes-minimum" ); @@ -4685,6 +5855,45 @@ mod tests { ); } + #[test] + fn provider_selection_allows_no_provider_for_stateless_operation() { + let ec = Ec::default(); + assert!(ec.provider.is_none(), "default Ec selects no provider"); + ec.validate_provider_selection() + .expect("should allow no provider selected and run statelessly"); + } + + #[test] + fn selecting_an_implementation_that_needs_settings_without_its_block_is_rejected() { + // The built-in HMAC provider has a required passphrase, so selecting + // it with no `[ec.hmac]` block is a deployment that would run + // stateless under a selector saying otherwise. + let toml_str = crate_test_settings_str_with_ec_section("[ec]\nprovider = \"hmac\"\n"); + + let err = Settings::from_toml(&toml_str) + .expect_err("an implementation with no settings block should fail at startup"); + assert!( + format!("{err:?}").contains("`hmac` is selected but has no `[ec.hmac]` configuration"), + "should name the missing block: {err:?}" + ); + } + + #[test] + fn selecting_a_provider_with_no_settings_needs_no_block() { + // A block exists only when a provider has settings, and only the + // adapter that injects a provider knows whether it has any, so a + // selector naming one core does not supply is left to + // `build_provider`, which is where the injected providers are known. + let toml_str = crate_test_settings_str_with_ec_section("[ec]\nprovider = \"acme\"\n"); + + let settings = Settings::from_toml(&toml_str) + .expect("a provider with no settings should need no block"); + assert!( + settings.ec.provider_blocks.is_empty(), + "the selection should stand on its own with no block configured" + ); + } + #[test] fn cache_asset_rule_globs_respect_path_separators() { let toml_str = format!( @@ -4791,6 +6000,25 @@ mod tests { ); } + #[test] + fn provider_blocks_without_a_selector_are_rejected() { + // A half-migrated configuration that carries an [ec.hmac] block but + // never selects it would silently run stateless, so it is rejected at + // startup instead. + let toml_str = crate_test_settings_str().replace("provider = \"hmac\"\n", ""); + + let err = Settings::from_toml(&toml_str) + .expect_err("a provider block with no selector should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_policy_validation_rejects_unsafe_config() { let missing_ttl = format!( @@ -4904,6 +6132,78 @@ mod tests { ); } + #[test] + fn legacy_passphrase_migrates_to_the_hmac_provider() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + ..Ec::default() + }; + ec.migrate_legacy_ec_layout() + .expect("should migrate the deprecated form"); + assert_eq!( + ec.provider.as_ref(), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), + "the deprecated passphrase should select the hmac provider" + ); + assert_eq!( + hmac_passphrase(&ec, HMAC_PROVIDER_KEY), + "test-secret-key-32-bytes-minimum", + "the passphrase should move into the hmac block" + ); + assert!( + ec.passphrase.is_none(), + "the deprecated field should be consumed by the migration" + ); + } + + /// The crate test configuration with its `[ec]` section rewritten to the + /// deprecated single-passphrase form. + fn legacy_ec_settings_str(passphrase: &str) -> String { + let legacy = crate_test_settings_str_with_ec_section(&format!( + "[ec]\npassphrase = \"{passphrase}\"\n" + )); + assert!( + !legacy.contains("[ec.hmac]"), + "the legacy configuration should carry no provider block" + ); + legacy + } + + #[test] + fn a_legacy_passphrase_is_held_to_the_passphrase_rules() { + // Validation runs before the migration and the deprecated field + // carries no check of its own, so the migration itself has to apply + // the passphrase rules. Without that, a value the new `[ec.hmac]` + // block rejects would still start a deployment from the old location. + let short = Settings::from_toml(&legacy_ec_settings_str("short")) + .expect_err("a short legacy passphrase should be rejected"); + assert!( + format!("{short:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {short:?}" + ); + + let empty = Settings::from_toml(&legacy_ec_settings_str("")) + .expect_err("an empty legacy passphrase should be rejected"); + assert!( + format!("{empty:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {empty:?}" + ); + + let settings = + Settings::from_toml(&legacy_ec_settings_str("test-secret-key-32-bytes-minimum")) + .expect("a legacy passphrase of adequate length should still start"); + assert_eq!( + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), + "an adequate legacy passphrase should still select the hmac provider" + ); + assert_eq!( + hmac_passphrase(&settings.ec, HMAC_PROVIDER_KEY), + "test-secret-key-32-bytes-minimum", + "an adequate legacy passphrase should still move into the hmac block" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( @@ -4945,40 +6245,564 @@ mod tests { "should explain invalid regex: {regex_err:?}" ); - let invalid_shape = format!( - r#"{} + let invalid_shape = format!( + r#"{} + + [[cache.asset_rules]] + id = "too-many-matchers" + enabled = true + path_prefix = "/assets/" + extensions = ["js"] + "#, + crate_test_settings_str() + ); + let shape_err = + Settings::from_toml(&invalid_shape).expect_err("should reject invalid matcher shape"); + assert!( + format!("{shape_err:?}").contains("exactly one matcher"), + "should explain invalid matcher shape: {shape_err:?}" + ); + + let missing_matcher = format!( + r#"{} + + [[cache.asset_rules]] + id = "missing-matcher" + enabled = true + browser_ttl_seconds = 60 + "#, + crate_test_settings_str() + ); + let missing_matcher_err = + Settings::from_toml(&missing_matcher).expect_err("should reject missing matcher"); + assert!( + format!("{missing_matcher_err:?}").contains("exactly one matcher"), + "should explain missing matcher: {missing_matcher_err:?}" + ); + } + + #[test] + fn legacy_passphrase_alongside_provider_config_is_rejected() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + ..Ec::default() + }; + let err = ec + .migrate_legacy_ec_layout() + .expect_err("both forms present should be rejected"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn an_unknown_key_in_the_hmac_provider_block_is_rejected() { + // A mistyped key in a provider block used to be dropped silently, which + // leaves the setting the operator meant to change at its default. + let toml_str = crate_test_settings_str().replace( + "passphrase = \"test-secret-key-32-bytes-minimum\"", + "passphrase = \"test-secret-key-32-bytes-minimum\"\n typo_key = \"x\"", + ); + assert!( + toml_str.contains("typo_key"), + "the test configuration should carry the unknown key" + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("an unknown key in [ec.hmac] should be rejected"); + assert!( + format!("{err:?}").contains("typo_key"), + "should name the unknown key: {err:?}" + ); + } + + #[test] + fn provider_none_is_explicit_stateless() { + let ec = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + ec.validate_provider_selection() + .expect("explicit none with no blocks should be valid"); + } + + #[test] + fn provider_none_with_configured_blocks_is_rejected() { + let mut ec = Ec::default(); + select_hmac_provider( + &mut ec, + HMAC_PROVIDER_KEY, + "test-secret-key-32-bytes-minimum", + ); + ec.provider = Some(EcProviderSelection::None); + assert!( + ec.validate_provider_selection().is_err(), + "none alongside configured blocks should be rejected" + ); + } + + #[test] + fn device_provider_defaults_to_builtin_and_rejects_unknown() { + let config = DeviceConfig::default(); + assert_eq!( + config.provider_key(), + "builtin", + "no selector should default to the built-in provider" + ); + config + .validate_provider_selection() + .expect("should validate the built-in default"); + + let fastly = DeviceConfig { + provider: Some("fastly".to_owned()), + }; + fastly + .validate_provider_selection() + .expect("should validate the fastly opt-in"); + + // As with geo, a key core does not know is no longer a settings error, + // because a module id is a legitimate value here too. + let module_key = DeviceConfig { + provider: Some("acme".to_owned()), + }; + module_key + .validate_provider_selection() + .expect("a module id should be accepted by settings validation"); + + // And as with geo, the rejection happens at registry build, so a + // mistyped selector cannot fall back to the built-in provider in + // silence. + let mut settings = crate::test_support::tests::create_test_settings(); + settings.device.provider = Some("acme".to_owned()); + let error = match crate::integrations::IntegrationRegistry::new(&settings) { + Ok(_) => { + panic!("a device provider no module supplies should be rejected at registry build") + } + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("acme") && message.contains("[device] provider"), + "the error should name the selector and the module, got: {message}" + ); + } + + #[test] + fn an_unselected_provider_block_is_rejected() { + // A vendor selector with the vendor block present, plus a stray hmac + // block, is almost always a stale or mistyped configuration. + let toml_str = crate_test_settings_str().replace( + "provider = \"hmac\"", + "provider = \"acme\"\n\n [ec.acme]\n api_key = \"example\"", + ); + let err = Settings::from_toml(&toml_str) + .expect_err("a configured but unselected block should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn the_old_host_signals_spelling_fails_at_startup_and_names_the_new_one() { + // The host-signal provider was renamed to `host_signals` under the + // rule that every name an operator types into configuration is + // `snake_case`. A deployment still configured with the old spelling + // has to stop when settings load, which every adapter does before it + // serves a request, and the error has to name the spelling to write + // instead. + let selecting = |selector: &str| { + crate_test_settings_str_with_ec_section(&format!( + "[ec]\nprovider = \"{selector}\"\n\n[ec.{selector}]\npassphrase = \"test-secret-key-32-bytes-minimum\"\n" + )) + }; + + // A block left under the old name is read as the block of a provider + // the adapter injects, so the old selector would find it and pass the + // block check if the name were not refused before the block is looked + // for. + let old = selecting(RETIRED_HOST_SIGNALS_PROVIDER_KEY); + let err = + Settings::from_toml(&old).expect_err("the old spelling should fail when settings load"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "the old spelling should be a configuration error, got: {:?}", + err.current_context() + ); + assert!( + err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), + "the error should name `host_signals`, got: {err}" + ); + + // With no block at all the answer has to be the same one, naming the + // new spelling rather than asking for a block under the old name. + let old_without_block = crate_test_settings_str_with_ec_section(&format!( + "[ec]\nprovider = \"{RETIRED_HOST_SIGNALS_PROVIDER_KEY}\"\n" + )); + let err = Settings::from_toml(&old_without_block) + .expect_err("the old spelling should fail with no block either"); + assert!( + err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), + "the error should still name `host_signals`, got: {err}" + ); + + // The same configuration written with the new spelling loads, and the + // block is read as the built-in provider's own settings rather than + // kept as the raw values of a provider an adapter injects. + let settings = Settings::from_toml(&selecting(HOST_SIGNALS_PROVIDER_KEY)) + .expect("the `host_signals` spelling should load"); + assert!( + settings + .ec + .provider_blocks + .get(HOST_SIGNALS_PROVIDER_KEY) + .and_then(EcProviderBlock::host_signals_settings) + .is_some(), + "the renamed block should be read as the host-signal provider's settings" + ); + } + + #[test] + fn a_labeled_block_the_selector_does_not_name_is_rejected() { + // A block under a label is still a provider block, so it is held to + // the rule every block is held to, which is that the selector names + // it. + let toml_str = crate_test_settings_str_with_ec_section( + r#"[ec] +provider = "hmac" + +[ec.hmac] +passphrase = "test-secret-key-32-bytes-minimum" + +[ec.primary] +implementation = "hmac" +passphrase = "another-test-secret-key-32-bytes" +"#, + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("a labeled block the selector does not name should fail at startup"); + let message = format!("{err:?}"); + assert!( + message.contains("[ec.primary] is configured but `hmac` is selected"), + "should name the unselected labeled block, got: {message}" + ); + } + + #[test] + fn the_removed_providers_table_is_rejected_with_its_new_location() { + let toml_str = crate_test_settings_str_with_ec_section( + "[ec]\nprovider = \"hmac\"\n\n[ec.providers.hmac]\npassphrase = \"test-secret-key-32-bytes-minimum\"\n", + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("the removed [ec.providers] table should be rejected"); + let message = format!("{err:?}"); + assert!( + message.contains("[ec.providers] is no longer read") && message.contains("[ec.hmac]"), + "should send the operator to the new location, got: {message}" + ); + } + + #[test] + fn a_key_under_ec_that_is_not_a_table_is_an_unknown_field() { + // Holding the provider blocks alongside the fixed keys costs the + // section serde's own unknown-key check, so a mistyped setting has to + // be caught where the blocks are read. + let toml_str = crate_test_settings_str_with_ec_section( + "[ec]\nprovider = \"hmac\"\nec_stor = \"ec_identity_store\"\n\n[ec.hmac]\npassphrase = \"test-secret-key-32-bytes-minimum\"\n", + ); + + let err = + Settings::from_toml(&toml_str).expect_err("a mistyped [ec] key should be rejected"); + let message = format!("{err:?}"); + assert!( + message.contains("unknown field `ec_stor`") && message.contains("`ec_store`"), + "should name the mistyped key and the keys it could have been, got: {message}" + ); + } + + #[test] + fn geo_provider_accepts_default_platform_and_none_and_rejects_unknown() { + let config = GeoConfig::default(); + assert!( + config.provider.is_none(), + "geo should default to no selector, which selects no geo provider" + ); + config + .validate_provider_selection() + .expect("should validate the default of running without geolocation"); + + let platform = GeoConfig { + provider: Some("platform".to_owned()), + assume_single_jurisdiction: false, + }; + platform + .validate_provider_selection() + .expect("should validate the explicit platform selection"); + + let none = GeoConfig { + provider: Some("none".to_owned()), + assume_single_jurisdiction: false, + }; + none.validate_provider_selection() + .expect("should validate the explicit opt-out of geolocation"); + + // A key core does not know is no longer a settings error, because a + // module id is a legitimate value and a closed list here would shut + // every module out of geo. Settings accepts it and the registry decides. + let module_key = GeoConfig { + provider: Some("acme".to_owned()), + assume_single_jurisdiction: false, + }; + module_key + .validate_provider_selection() + .expect("a module id should be accepted by settings validation"); + + // The rejection moved to registry build, where it is known whether any + // module supplies the name. With no module supplying `acme`, building + // the registry fails and the error names the selector and the module. + let mut settings = crate::test_support::tests::create_test_settings(); + settings.geo.provider = Some("acme".to_owned()); + // `IntegrationRegistry` is not `Debug`, so the error is taken by match + // rather than `expect_err`. + let error = match crate::integrations::IntegrationRegistry::new(&settings) { + Ok(_) => { + panic!("a geo provider no module supplies should be rejected at registry build") + } + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("acme") && message.contains("[geo] provider"), + "the error should name the selector and the module, got: {message}" + ); + } + + #[test] + fn the_reserved_ec_keys_are_the_ones_the_section_reads() { + // The list is written out for the unknown-field message and the + // reserved-name check, so it has to stay level with the struct. + let ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + ..Ec::default() + }; + let value = serde_json::to_value(&ec).expect("should serialize the [ec] section"); + let written = value + .as_object() + .expect("the section should serialize as a table") + .keys() + .map(String::as_str) + .collect::>(); + + assert_eq!( + written, + EC_SECTION_KEYS.iter().copied().collect::>(), + "EC_SECTION_KEYS should name every key the [ec] section reads as its own" + ); + } + + #[test] + fn unknown_keys_in_provider_sections_are_rejected() { + // A mistyped key must fail at startup rather than silently selecting + // a default behind the operator's back. + for (section, bad_key) in [ + ("[geo]", "providr = \"platform\""), + ("[device]", "providr = \"builtin\""), + ] { + let toml_str = format!( + "{}\n\n {section}\n {bad_key}\n", + crate_test_settings_str() + ); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in {section} should be rejected" + ); + } + + let toml_str = crate_test_settings_str() + .replace("[ec.hmac]", "[ec.hmac]\n unexpected = \"value\""); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in [ec.hmac] should be rejected" + ); + } + + #[test] + fn a_reserved_key_cannot_name_a_provider() { + let toml_str = crate_test_settings_str_with_ec_section("[ec]\nprovider = \"ec_store\"\n"); + + let err = + Settings::from_toml(&toml_str).expect_err("a reserved key should not name a provider"); + assert!( + format!("{err:?}").contains("names a key the `[ec]` section reads as its own setting"), + "should say why the name cannot be used: {err:?}" + ); + } + + #[test] + fn provider_names_and_implementations_are_snake_case() { + for ec_section in [ + "[ec]\nprovider = \"Primary\"\n", + "[ec]\nprovider = \"primary\"\n\n[ec.primary]\nimplementation = \"Hmac\"\n", + "[ec]\nprovider = \"primary\"\n\n[ec.Primary]\nimplementation = \"hmac\"\npassphrase = \"test-secret-key-32-bytes-minimum\"\n", + ] { + let err = Settings::from_toml(&crate_test_settings_str_with_ec_section(ec_section)) + .expect_err("a name outside snake_case should be rejected"); + assert!( + format!("{err:?}").contains("must be snake_case"), + "should hold `{ec_section}` to the snake_case rule: {err:?}" + ); + } + } + + #[test] + fn an_hmac_block_without_its_passphrase_names_the_block() { + // The implementation the block names decides how its settings are + // read, so the block the operator wrote is what the error names. + let toml_str = crate_test_settings_str_with_ec_section( + "[ec]\nprovider = \"primary\"\n\n[ec.primary]\nimplementation = \"hmac\"\n", + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("an hmac block without its passphrase should be rejected"); + let message = format!("{err:?}"); + assert!( + message.contains("[ec.primary] is invalid") && message.contains("passphrase"), + "should name the block and the setting it lacks, got: {message}" + ); + } + + #[test] + fn a_labeled_block_round_trips_through_serialization() { + // The pushed configuration is the serialized settings, so a label and + // the implementation it names have to survive being written back. + let toml_str = crate_test_settings_str_with_ec_section( + "[ec]\nprovider = \"primary\"\n\n[ec.primary]\nimplementation = \"hmac\"\npassphrase = \"test-secret-key-32-bytes-minimum\"\n", + ); + let settings = + Settings::from_toml(&toml_str).expect("should parse a labeled provider block"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + assert_eq!( + value["ec"]["primary"]["implementation"], "hmac", + "the label should keep the implementation it names" + ); - [[cache.asset_rules]] - id = "too-many-matchers" - enabled = true - path_prefix = "/assets/" - extensions = ["js"] - "#, - crate_test_settings_str() + let reparsed = + Settings::from_json_value(value).expect("should reparse the serialized settings"); + assert_eq!( + reparsed.ec.provider_blocks.implementation("primary"), + HMAC_PROVIDER_KEY, + "the implementation should survive the round trip" ); - let shape_err = - Settings::from_toml(&invalid_shape).expect_err("should reject invalid matcher shape"); + assert_eq!( + hmac_passphrase(&reparsed.ec, "primary"), + "test-secret-key-32-bytes-minimum" + ); + + // A block under the implementation's own name is written back as it + // was, without gaining a key the operator never wrote. + let written = serde_json::to_value(create_test_settings()) + .expect("should serialize the test settings"); assert!( - format!("{shape_err:?}").contains("exactly one matcher"), - "should explain invalid matcher shape: {shape_err:?}" + written["ec"]["hmac"].get("implementation").is_none(), + "an unlabeled block should not gain an implementation key: {}", + written["ec"]["hmac"] ); + } - let missing_matcher = format!( - r#"{} + #[test] + fn an_injected_providers_settings_are_kept_as_written() { + // Core never names a vendor, so the block of a provider an adapter + // injects is kept as the values it held for that adapter to read. + let toml_str = crate_test_settings_str_with_ec_section( + "[ec]\nprovider = \"acme\"\n\n[ec.acme]\nendpoint = \"https://ec.acme.example.com\"\n", + ); + let settings = + Settings::from_toml(&toml_str).expect("should parse a vendor provider block"); - [[cache.asset_rules]] - id = "missing-matcher" - enabled = true - browser_ttl_seconds = 60 - "#, - crate_test_settings_str() + let block = settings + .ec + .provider_blocks + .get("acme") + .expect("should configure the acme block"); + assert_eq!( + settings.ec.provider_blocks.implementation("acme"), + "acme", + "a block that names no implementation is its own name's" ); - let missing_matcher_err = - Settings::from_toml(&missing_matcher).expect_err("should reject missing matcher"); + let EcProviderSettings::Injected(injected) = &block.settings else { + panic!("a vendor block should keep its settings as the raw values it held"); + }; + assert_eq!(injected["endpoint"], "https://ec.acme.example.com"); + + // They survive being written back into the pushed configuration, so + // the adapter reads what the operator wrote. + let written = serde_json::to_value(&settings).expect("should serialize settings"); + assert_eq!( + written["ec"]["acme"]["endpoint"], + "https://ec.acme.example.com" + ); + } + + #[test] + fn the_compiled_permission_policy_validates_at_startup() { + // The compiled-in sample declares its top node, so startup + // accepts it. A policy that omitted `group` or `jurisdiction` would be + // rejected here rather than panicking on the first lookup, which the + // parser tests in `permissions` cover directly. + GeoConfig::validate_permission_policy() + .expect("the compiled-in sample should validate at startup"); + } + + #[test] + fn ec_without_geo_requires_the_single_jurisdiction_acknowledgment() { + // The base test settings acknowledge single-jurisdiction operation. + // Removing the acknowledgment while an EC provider is configured and + // no geo provider is selected must fail at startup. + let toml_str = crate_test_settings_str().replace("assume_single_jurisdiction = true\n", ""); + let err = Settings::from_toml(&toml_str) + .expect_err("an EC provider with no geo provider needs the acknowledgment"); assert!( - format!("{missing_matcher_err:?}").contains("exactly one matcher"), - "should explain missing matcher: {missing_matcher_err:?}" + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() ); + + // Selecting a geo provider removes the requirement. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("[geo]", "[geo]\n provider = \"platform\""); + Settings::from_toml(&toml_str) + .expect("a geo provider resolves jurisdictions, so no acknowledgment is needed"); + + // With no EC provider there is no jurisdiction consumer to protect. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("provider = \"hmac\"", "") + .replace( + "[ec.hmac]\n passphrase = \"test-secret-key-32-bytes-minimum\"", + "", + ); + Settings::from_toml(&toml_str) + .expect("stateless operation needs no jurisdiction acknowledgment"); } #[test] @@ -5393,7 +7217,11 @@ source_domain = "partner.example.com" let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); - settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + select_hmac_provider( + &mut settings.ec, + HMAC_PROVIDER_KEY, + "test-secret-key-32-bytes-minimum", + ); settings.handlers[0].password = Redacted::new("replace-with-admin-password-32-bytes".to_owned()); @@ -5951,7 +7779,13 @@ source_domain = "partner.example.com" proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -5981,107 +7815,93 @@ source_domain = "partner.example.com" proxy_secret = "unit-test-proxy-secret" max_buffered_body_bytes = 0 + [geo] + assume_single_jurisdiction = true + [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ); + let error = result.expect_err("should reject a zero buffered-body cap"); assert!( - result.is_err(), - "publisher.max_buffered_body_bytes = 0 must fail config validation" + error.to_string().contains("max_buffered_body_bytes"), + "the rejection should be for the zero cap, not another validation, got: {error}" ); } + /// An integration `[integration] provider` does not name has no + /// configuration, whatever else the settings hold. #[test] - fn test_disabled_integration_does_not_register() { + fn an_integration_that_is_not_named_has_no_configuration() { use crate::integrations::testlight::TestlightConfig; - use serde_json::json; - - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "testlight", - &json!({ - "enabled": false, - "endpoint": "https://testlight.test/auction", - "rewrite_scripts": true, - }), - ) - .expect("should insert integration config"); - let config = settings - .integration_config::("testlight") - .expect("integration parsing should succeed"); + let settings = create_test_settings(); - assert!(config.is_none(), "Disabled integrations should be skipped"); + assert!( + !settings.integration.is_selected("testlight"), + "the shared fixture should not name testlight" + ); + assert!( + settings + .integration_config::("testlight") + .expect("reading an unnamed integration should succeed") + .is_none(), + "an integration that is not named should have no configuration" + ); } + /// A named integration with no block of its own is read from an empty one, + /// so one that takes no settings runs on its id alone. #[test] - fn disabled_integration_can_omit_enabled_required_fields_and_skip_semantic_validation() { + fn a_named_integration_with_no_block_is_read_from_an_empty_one() { + use crate::integrations::osano::OsanoConfig; + let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "gpt", - &json!({ - "enabled": false, - }), - ) - .expect("should insert GPT config"); + settings.integration.select("osano"); - let config = settings - .integration_config::("gpt") - .expect("minimal disabled GPT config should be ignored"); - assert!(config.is_none(), "disabled GPT config should be skipped"); - IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), - ) - .expect("disabled invalid integration config should not fail registry startup"); + assert!( + settings + .integration_config::("osano") + .expect("an integration that takes no settings should read from an empty block") + .is_some(), + "naming the integration should be the whole configuration" + ); } + /// The same empty block makes an integration that requires a setting report + /// the setting it is missing, rather than starting without it. #[test] - fn minimal_disabled_prebid_deserializes_without_enabled_only_validation() { + fn a_named_integration_without_a_required_setting_names_it() { + use crate::integrations::testlight::TestlightConfig; + let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "prebid", - &json!({ - "enabled": false, - }), - ) - .expect("should insert prebid config"); + settings.integration.select("testlight"); - let config = settings - .integration_config::("prebid") - .expect("disabled prebid config should be ignored"); - assert!(config.is_none(), "disabled prebid config should be skipped"); - IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), - ) - .expect("disabled default-enabled prebid config should not fail registry startup"); - build_orchestrator(&settings) - .expect("minimal disabled prebid config should not fail orchestrator startup"); + let error = settings + .integration_config::("testlight") + .expect_err("should reject a named integration with no endpoint"); + + let rendered = error.to_string(); + assert!( + rendered.contains("testlight") && rendered.contains("endpoint"), + "should name the integration and the missing setting: {rendered}" + ); } #[test] - fn disabled_removed_prebid_and_aps_fields_are_rejected() { - for (integration_id, removed_field) in [("prebid", "server_url"), ("aps", "account_id")] { + fn removed_integration_fields_are_rejected() { + for (integration_id, removed_field) in + [("prebid", "server_url"), ("datadome", "account_id")] + { let mut settings = create_test_settings(); settings - .integrations + .integration .insert_config( integration_id, &json!({ - "enabled": false, (removed_field): "removed-value", }), ) @@ -6090,10 +7910,12 @@ source_domain = "partner.example.com" let error = match integration_id { "prebid" => settings .integration_config::(integration_id) - .expect_err("should reject removed disabled Prebid field"), - "aps" => settings - .integration_config::(integration_id) - .expect_err("should reject removed disabled APS field"), + .expect_err("should reject the removed Prebid field"), + "datadome" => settings + .integration_config::( + integration_id, + ) + .expect_err("should reject the removed DataDome field"), _ => unreachable!("test integration ID should be known"), }; assert!( @@ -6103,15 +7925,122 @@ source_domain = "partner.example.com" } } + /// A block written for an integration the provider list does not name is + /// refused, rather than sitting in the configuration doing nothing. + #[test] + fn a_block_for_an_integration_that_is_not_named_is_refused() { + let toml = format!( + "{}\n[integration.osano]\n", + crate_test_settings_str().replace( + "provider = [\"prebid\"]", + "provider = [\"prebid\"]\n\n[integration.nextjs]\nrewrite_attributes = [\"href\"]", + ) + ); + + let error = + Settings::from_toml(&toml).expect_err("should reject blocks nothing on the list names"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("[integration.nextjs]") && rendered.contains("[integration.osano]"), + "should name every block that is not on the list: {rendered}" + ); + assert!( + rendered.contains("provider"), + "should say where to name the integration instead: {rendered}" + ); + } + + /// The removed `enabled` key is refused where it is written, so a + /// configuration carried over from the previous release cannot read as + /// switched off while the integration runs. + #[test] + fn an_enabled_key_left_in_a_block_is_refused() { + let toml = crate_test_settings_str().replace( + "[integration.prebid]", + "[integration.prebid]\nenabled = false", + ); + + let error = Settings::from_toml(&toml).expect_err("should reject a leftover enabled key"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("[integration.prebid]") && rendered.contains("enabled"), + "should name the block and the key: {rendered}" + ); + assert!( + rendered.contains("[integration] provider"), + "should say what switches an integration on instead: {rendered}" + ); + } + + /// Naming one integration twice is a mistake rather than a way of running + /// it twice, so it is refused. + #[test] + fn naming_an_integration_twice_is_refused() { + let toml = crate_test_settings_str().replace( + "provider = [\"prebid\"]", + "provider = [\"prebid\", \"prebid\"]", + ); + + let error = Settings::from_toml(&toml).expect_err("should reject a repeated id"); + + assert!( + format!("{error:?}").contains("more than once"), + "should report the repeated id: {error:?}" + ); + } + + /// The table this release removed is refused with the move spelled out, + /// rather than with a bare unknown-field error. + #[test] + fn the_removed_integrations_table_is_refused_with_directions() { + let toml = crate_test_settings_str().replace("[integration]", "[integrations]"); + + let error = Settings::from_toml(&toml).expect_err("should reject the removed table"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("[integration.]") && rendered.contains("[integration] provider"), + "should say where the blocks moved: {rendered}" + ); + assert!( + rendered.contains("CHANGELOG.md"), + "should point at the migration: {rendered}" + ); + } + + /// The same refusal reaches a configuration blob, which is the shape the + /// runtime loads rather than TOML. + #[test] + fn json_settings_refuse_the_removed_integrations_table() { + let mut value = serde_json::to_value(create_test_settings()) + .expect("should serialize the test settings fixture to JSON"); + let settings = value + .as_object_mut() + .expect("settings should serialize as an object"); + let integration = settings + .remove("integration") + .expect("the fixture should serialize its integration table"); + settings.insert("integrations".to_owned(), integration); + + let error = + Settings::from_json_value(value).expect_err("should reject the removed table in JSON"); + + assert!( + format!("{error:?}").contains("[integration] provider"), + "should say where the blocks moved: {error:?}" + ); + } + #[test] - fn enabled_invalid_integration_fails_registry_startup() { + fn invalid_settings_for_a_named_integration_fail_registry_startup() { let mut settings = create_test_settings(); settings - .integrations + .integration .insert_config( "gpt", &json!({ - "enabled": true, "script_url": "not a url", }), ) @@ -6124,7 +8053,7 @@ source_domain = "partner.example.com" .expect("should compile auction plan"), ), ) { - Ok(_) => panic!("enabled invalid integration should fail registry startup"), + Ok(_) => panic!("a named integration with invalid settings should fail startup"), Err(err) => err, }; assert!( @@ -7113,8 +9042,14 @@ source_domain = "partner.example.com" proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" @@ -7445,7 +9380,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7529,7 +9470,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7565,7 +9512,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7607,7 +9560,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7708,6 +9667,29 @@ formats = [{{ width = 300, height = 250 }}] ); } + /// An unset selector must not serialize as `"provider": null`. + /// + /// The section as a whole is skipped while every field is default, so this + /// serializes the struct directly. Once a later change makes another field + /// required, the section is always emitted and a null selector would then + /// reach a config blob, where a binary that predates the field rejects it. + #[test] + fn an_unset_provider_selector_is_omitted_from_the_serialized_section() { + let geo = GeoConfig::default(); + let json = serde_json::to_string(&geo).expect("should serialize the geo section"); + assert!( + !json.contains("provider"), + "an unset geo selector should be omitted rather than serialized as null, got {json}" + ); + + let device = DeviceConfig::default(); + let json = serde_json::to_string(&device).expect("should serialize the device section"); + assert!( + !json.contains("provider"), + "an unset device selector should be omitted rather than serialized as null, got {json}" + ); + } + #[test] fn admin_endpoints_match_fastly_router() { let router_source = include_str!("../../trusted-server-adapter-fastly/src/app.rs"); @@ -7750,3 +9732,159 @@ formats = [{{ width = 300, height = 250 }}] } } } + +#[cfg(test)] +mod permission_signal_config_tests { + use super::*; + use serde_json::json; + + use crate::config::TrustedServerAppConfig; + use crate::test_support::tests::crate_test_settings_str; + + // Which names are valid is only known where the scheme crates are linked, + // so the checks that a name matches an available provider, and that none + // is repeated, live with the seam in `permission_signal::select`. What is + // tested here is the shape of the section itself. + + /// The test fixture's configuration with `section` written as its + /// `[permission_signal]` section. + fn settings_toml_with(section: &str) -> String { + format!( + "{}\n[permission_signal]\n{section}\n", + crate_test_settings_str() + ) + } + + #[test] + fn no_section_is_allowed_and_means_every_provider() { + let config = PermissionSignalConfig::default(); + assert!( + config.provider.is_none(), + "absent rather than empty, because the two mean opposite things" + ); + } + + #[test] + fn the_section_round_trips_through_toml() { + let parsed: PermissionSignalConfig = + toml::from_str(r#"provider = ["gpc", "tcf"]"#).expect("should parse the section"); + assert_eq!( + parsed.provider.as_deref(), + Some(["gpc".to_owned(), "tcf".to_owned()].as_slice()), + "the order written is the order read, because the order is the policy" + ); + } + + #[test] + fn the_section_round_trips_through_a_config_blob() { + // The section is written by derive and read by hand, so what a push + // writes into a blob must be what a deployment reads back from it. + let written = PermissionSignalConfig { + provider: Some(vec!["tcf".to_owned(), "gpc".to_owned()]), + }; + let blob = serde_json::to_value(&written).expect("should write the section"); + let read: PermissionSignalConfig = + serde_json::from_value(blob).expect("should read back what was written"); + assert_eq!(read, written, "the list and its order survive the blob"); + + let null: PermissionSignalConfig = serde_json::from_value(json!({ "provider": null })) + .expect("should read an explicit null"); + assert_eq!( + null, + PermissionSignalConfig::default(), + "an explicit null is the same as leaving the key out" + ); + } + + #[test] + fn an_empty_list_is_kept_apart_from_no_list() { + let parsed: PermissionSignalConfig = + toml::from_str("provider = []").expect("should parse an empty list"); + assert_eq!( + parsed.provider.as_deref(), + Some(&[][..]), + "a publisher acting on no signal at all writes an empty list, and it must \ + not read back as having written nothing" + ); + } + + #[test] + fn an_unknown_key_is_refused() { + let error = toml::from_str::(r#"providers = ["gpc"]"#) + .expect_err("should refuse a misspelled key rather than silently ignore it"); + assert!( + error + .to_string() + .contains("unknown field `providers` in [permission_signal], expected `provider`"), + "the refusal names the key it did not recognize and the one it accepts: {error}" + ); + } + + #[test] + fn the_removed_sources_key_is_refused_naming_provider() { + for written in [ + r#"sources = ["gpc", "tcf"]"#, + "provider = [\"gpc\", \"tcf\"]\nsources = [\"gpc\", \"tcf\"]", + ] { + let error = toml::from_str::(written) + .expect_err("should refuse the removed key, alone or beside its replacement"); + assert!( + error.to_string().contains( + "[permission_signal] sources is no longer accepted. Name the providers \ + to run, in order, in [permission_signal] provider instead" + ), + "the refusal says which key to write instead: {error}" + ); + } + } + + #[test] + fn every_way_settings_are_read_refuses_the_removed_sources_key() { + let written = settings_toml_with(r#"sources = ["gpc", "tcf"]"#); + + let error = Settings::from_toml(&written).expect_err("should refuse the removed key"); + assert!( + format!("{error:?}").contains("[permission_signal] provider"), + "reading a TOML file names the key that replaced it: {error:?}" + ); + + // `ts config push` parses the file into a TOML value before reading the + // settings from it. + let value: toml::Value = toml::from_str(&written).expect("should parse as TOML"); + let error = value + .try_into::() + .expect_err("should refuse the removed key before a push"); + assert!( + error.to_string().contains("[permission_signal] provider"), + "a push names the key that replaced it: {error}" + ); + + // A deployment reads its settings from a JSON config blob. + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should load the test settings fixture"); + let mut blob = serde_json::to_value(settings).expect("should serialize the fixture"); + blob["permission_signal"] = json!({ "sources": ["gpc", "tcf"] }); + let error = + Settings::from_json_value(blob).expect_err("should refuse the removed key at startup"); + assert!( + format!("{error:?}").contains("[permission_signal] provider"), + "startup names the key that replaced it: {error:?}" + ); + } + + #[test] + fn a_block_of_provider_settings_is_refused_as_an_unknown_field() { + // No provider takes settings yet, so a block for one is refused rather + // than read and then ignored. + let written = + settings_toml_with("provider = [\"gpc\"]\n\n[permission_signal.gpc]\nenabled = true"); + let error = + Settings::from_toml(&written).expect_err("should refuse settings no provider takes"); + let message = format!("{error:?}"); + assert!( + message.contains("unknown field `gpc` in [permission_signal]") + && message.contains("[permission_signal.] block is not accepted"), + "the refusal names the block and says why it is refused: {message}" + ); + } +} diff --git a/crates/trusted-server-core/src/tdl.rs b/crates/trusted-server-core/src/tdl.rs new file mode 100644 index 000000000..a2202cb83 --- /dev/null +++ b/crates/trusted-server-core/src/tdl.rs @@ -0,0 +1,191 @@ +//! Terms Document Locators, the labels saying what terms the data offered for +//! a request is available under. +//! +//! A locator is the address of a published document a person can read, stating +//! the basis on which the data at hand may be collected, shared and used. A +//! recipient reads the locators alongside the data, decides whether those terms +//! are ones it accepts, and decides on the same basis whether it may pass the +//! data on. No locator means no terms are declared, which a recipient must not +//! read as permission. +//! +//! Nothing here interprets a document. Core carries the locators a +//! [`PermissionSignalProvider`](crate::permission_signal::PermissionSignalProvider) +//! declares for the request and makes them visible to what consumes the data, +//! and the provider for a terms scheme decides which document applies. Model +//! Terms for Marketing (MTM) is the first such scheme to arrive and one of many +//! rather than the only one, because a publisher, a trade body or a regulator +//! can each publish terms and each set becomes a provider. +//! +//! # The document must not change +//! +//! A locator has to point at a document that is never edited once published, +//! which is why a version belongs in its address. A document that can be +//! rewritten tomorrow means a recipient can never prove what it agreed to, and +//! one edit silently rewrites the basis of every transaction already sent under +//! it. That is a property of how the document is published, so no code here can +//! check it, and it is the reason this type refuses nothing but an address that +//! could not be fetched at all. + +use core::str::FromStr; + +use error_stack::Report; +use url::Url; + +use crate::error::TrustedServerError; + +/// The address of a published terms document. +/// +/// Absolute, and `http` or `https`, because a recipient has to be able to +/// fetch and read the document. See the module documentation for why the +/// document itself must be immutable and versioned. +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub struct Tdl(String); + +impl Tdl { + /// Builds a locator from an address. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the address is not an + /// absolute `http` or `https` URL with a host, because a recipient given + /// one of those has nothing it can fetch. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::tdl::Tdl; + /// + /// let tdl = Tdl::new("https://terms.example.com/marketing/2.txt") + /// .expect("should accept an absolute https address"); + /// assert_eq!(tdl.as_str(), "https://terms.example.com/marketing/2.txt"); + /// + /// assert!(Tdl::new("/marketing/2.txt").is_err()); + /// ``` + pub fn new(locator: &str) -> Result> { + let parsed = Url::parse(locator).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "Terms document locator `{locator}` is not an absolute URL: {error}" + ), + }) + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Terms document locator `{locator}` must be http or https, so a \ + recipient can read the document, and not `{}`", + parsed.scheme() + ), + })); + } + if parsed.host().is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("Terms document locator `{locator}` names no host"), + })); + } + Ok(Self(locator.to_owned())) + } + + /// The address, as it is carried to a recipient. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for Tdl { + type Err = Report; + + fn from_str(locator: &str) -> Result { + Self::new(locator) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absolute_https_address_is_accepted() { + let tdl = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + assert_eq!( + tdl.as_str(), + "https://terms.example.com/marketing/2.txt", + "should carry the address unchanged" + ); + } + + #[test] + fn an_absolute_http_address_is_accepted() { + assert!( + Tdl::new("http://terms.example.com/marketing/2.txt").is_ok(), + "should accept http, because a document served over http is still readable" + ); + } + + #[test] + fn a_relative_address_is_refused() { + let error = Tdl::new("/marketing/2.txt") + .expect_err("should refuse an address a recipient cannot resolve"); + assert!( + format!("{error:?}").contains("absolute"), + "should say the address is not absolute" + ); + } + + #[test] + fn another_scheme_is_refused() { + let error = + Tdl::new("mailto:terms@example.com").expect_err("should refuse a scheme with no page"); + assert!( + format!("{error:?}").contains("http or https"), + "should name the schemes a recipient can read" + ); + } + + #[test] + fn an_empty_address_is_refused() { + assert!( + Tdl::new("").is_err(), + "should refuse an empty address rather than carry it to a recipient" + ); + } + + #[test] + fn parsing_from_a_string_gives_the_same_answer() { + let parsed: Tdl = "https://terms.example.com/marketing/2.txt" + .parse() + .expect("should parse an absolute https address"); + assert_eq!( + parsed, + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"), + "should match the constructor" + ); + } + + #[test] + fn two_locators_for_the_same_document_compare_equal() { + let one = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + let two = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + assert_eq!( + one, two, + "should compare equal so the same document is carried once" + ); + } + + #[test] + fn versions_of_one_document_are_different_locators() { + let two = Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept an absolute https address"); + let three = Tdl::new("https://terms.example.com/marketing/3.txt") + .expect("should accept an absolute https address"); + assert_ne!( + two, three, + "should distinguish versions, because a recipient agreed to one of them" + ); + } +} diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index d6e85a167..2d23d0c69 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -1,6 +1,10 @@ #[cfg(test)] pub mod tests { - use crate::settings::Settings; + use crate::ec::provider::{EcProviderSelection, HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY}; + use crate::redacted::Redacted; + use crate::settings::{ + Ec, EcProviderBlock, HmacProviderConfig, HostSignalsProviderConfig, Settings, + }; #[must_use] pub fn crate_test_settings_str() -> String { @@ -21,19 +25,29 @@ pub mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" - [integrations.prebid] - enabled = true + [geo] + # A gdpr-eu country, where every permission requires a signal. This + # reproduces the prior no-default floor, so existing tests are + # unaffected by the now-required default. + # Tests run with no geo provider, so single-jurisdiction operation + # is acknowledged the same way a deployment would. + assume_single_jurisdiction = true + + [integration] + provider = ["prebid"] + + [integration.prebid] external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" - [integrations.prebid.bundle.modules] + [integration.prebid.bundle.modules] bidder = ["exampleBidderBidAdapter"] - [integrations.nextjs] - enabled = false - rewrite_attributes = ["href", "link", "url"] - [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" @@ -41,6 +55,37 @@ pub mod tests { .to_owned() } + /// The shared fixture TOML with `integration_id` named in + /// `[integration] provider` as well, for a test that appends that + /// integration's own block. + #[must_use] + pub fn crate_test_settings_str_running(integration_id: &str) -> String { + crate_test_settings_str().replace( + "provider = [\"prebid\"]", + &format!("provider = [\"prebid\", \"{integration_id}\"]"), + ) + } + + /// The crate test configuration with its whole `[ec]` section replaced by + /// `ec_section`, which carries its own `[ec]` header and any provider + /// blocks. + /// + /// # Panics + /// + /// Panics if the embedded TOML configuration no longer has an `[ec]` + /// section followed by a `[request_signing]` section. + #[must_use] + pub fn crate_test_settings_str_with_ec_section(ec_section: &str) -> String { + let base = crate_test_settings_str(); + let (before, rest) = base + .split_once("[ec]") + .expect("should find the [ec] section in the test settings"); + let (_, after) = rest + .split_once("[request_signing]") + .expect("should find the [request_signing] section in the test settings"); + format!("{before}{ec_section}\n\n[request_signing]{after}") + } + #[must_use] /// Creates test settings from embedded TOML configuration. /// @@ -54,6 +99,53 @@ pub mod tests { settings } + /// Selects the built-in HMAC provider under `name` with `passphrase`, + /// replacing whatever Edge Cookie provider the settings carried. + /// + /// A `name` other than `hmac` is a label, so the block names the + /// implementation it configures. + pub fn select_hmac_provider(ec: &mut Ec, name: &str, passphrase: &str) { + let mut block = EcProviderBlock::from(HmacProviderConfig { + passphrase: Redacted::new(passphrase.to_owned()), + }); + if name != HMAC_PROVIDER_KEY { + block.implementation = Some(HMAC_PROVIDER_KEY.to_owned()); + } + ec.provider = Some(EcProviderSelection::from(name)); + ec.provider_blocks.clear(); + ec.provider_blocks.insert(name.to_owned(), block); + } + + /// Selects the built-in host-signal provider under its own name with + /// `passphrase`, replacing whatever Edge Cookie provider the settings + /// carried. + pub fn select_host_signals_provider(ec: &mut Ec, passphrase: &str) { + ec.provider = Some(EcProviderSelection::from(HOST_SIGNALS_PROVIDER_KEY)); + ec.provider_blocks.clear(); + ec.provider_blocks.insert( + HOST_SIGNALS_PROVIDER_KEY.to_owned(), + EcProviderBlock::from(HostSignalsProviderConfig { + passphrase: Redacted::new(passphrase.to_owned()), + }), + ); + } + + /// The passphrase the block `name` holds. + /// + /// # Panics + /// + /// Panics if `name` has no block, or if its block configures another + /// provider. + #[must_use] + pub fn hmac_passphrase<'a>(ec: &'a Ec, name: &str) -> &'a str { + ec.provider_blocks + .get(name) + .and_then(EcProviderBlock::hmac_settings) + .unwrap_or_else(|| panic!("settings should configure the hmac provider under `{name}`")) + .passphrase + .expose() + } + /// A valid EC ID in `{64-hex}.{6-alnum}` format for use in tests. pub const VALID_SYNTHETIC_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234"; @@ -110,30 +202,44 @@ pub mod nextjs_auction { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + # The fixture supplies its own geo provider through the runtime + # services, so it selects the host lookup to reach it. Without a + # selection the jurisdiction is unknown, and a server-side auction + # fails closed on an unknown jurisdiction. + [geo] + provider = "platform" "#, ) .expect("should parse Next.js auction fixture settings"); settings - .integrations + .integration .insert_config( "nextjs", &serde_json::json!({ - "enabled": true, "rewrite_attributes": ["href", "link", "url"], }), ) - .expect("should enable the fixture Next.js integration"); + .expect("should select the fixture Next.js integration"); settings.auction.enabled = true; - settings.auction.mediator = None; - settings.auction.providers = serde_json::from_value(serde_json::json!({ - "fixture": { - "protocol": "openrtb-2.6", - "endpoint": "https://auction.example.com/bid", - "routing": "all_eligible", - "timeout_ms": 5000 - } - })) - .expect("should configure the fixture auction provider"); + // One `[demand.fixture]` source, which is where an auction provider is + // configured now that `[auction.providers]` is gone. `implementation` + // states the wire format, so there is no separate `protocol`. + settings.demand = crate::provider_table::ProviderTable::new( + vec!["fixture".to_owned()], + std::collections::BTreeMap::from([( + "fixture".to_owned(), + serde_json::Map::from_iter([ + ("implementation".to_owned(), serde_json::json!("openrtb")), + ( + "endpoint".to_owned(), + serde_json::json!("https://auction.example.com/bid"), + ), + ("routing".to_owned(), serde_json::json!("all_eligible")), + ("timeout_ms".to_owned(), serde_json::json!(5000)), + ]), + )]), + ); settings.creative_opportunities = Some( toml::from_str( r#" @@ -277,10 +383,12 @@ pub mod nextjs_auction { struct FixtureGeo; + #[async_trait::async_trait(?Send)] impl PlatformGeo for FixtureGeo { - fn lookup( + async fn lookup( &self, _client_ip: Option, + _services: &RuntimeServices, ) -> Result, Report> { Ok(Some(GeoInfo { country: "AU".to_owned(), diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index e8275b814..8cb2216a2 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,23 +1,29 @@ -use trusted_server_js::{concatenated_hash, single_module_hash}; +//! URLs and `", - tsjs_script_src(module_ids), + tsjs_script_src(parts), ) } -/// `/static` URL for the unified bundle when exact module IDs are unavailable. +/// `/static` URL for the unified bundle when the exact parts are unavailable. /// /// This intentionally omits `?v=` because the serving path can only mark a URL /// immutable when the hash matches the exact enabled module set. Use -/// [`tsjs_script_src`] with exact module IDs when [`IntegrationRegistry`] is -/// available. +/// [`tsjs_script_src`] with the registry's parts when [`IntegrationRegistry`] +/// is available. /// /// [`IntegrationRegistry`]: crate::integrations::IntegrationRegistry #[must_use] @@ -59,7 +65,7 @@ pub fn tsjs_unified_script_src() -> String { "/static/tsjs=tsjs-unified.min.js".to_string() } -/// `", - tsjs_deferred_script_src(module_id) + tsjs_deferred_script_src(part) ) } -/// Generate all deferred `"), "should generate exactly one trusted server script tag" ); @@ -204,18 +251,18 @@ mod tests { #[test] fn publisher_tsjs_script_tag_renders_static_attributes() { - let module_ids = ["gpt"]; - let src = tsjs_script_src(&module_ids); + let parts = compile_time_parts(&["gpt"]); + let src = tsjs_script_src(&parts); assert_eq!( - tsjs_script_tag_with_attributes(&module_ids, &[("data-ts-gam-attribution", "true")]), + tsjs_script_tag_with_attributes(&parts, &[("data-ts-gam-attribution", "true")]), format!( "" ), "should render trusted static attributes on the publisher bundle tag" ); assert_eq!( - tsjs_script_tag(&module_ids), + tsjs_script_tag(&parts), format!(""), "should keep the generic tag byte-for-byte unmarked" ); @@ -226,7 +273,10 @@ mod tests { expected = "attribute name should contain only lowercase ASCII letters, digits, and hyphens" )] fn publisher_tsjs_script_tag_rejects_invalid_attribute_name() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-bad_name", "true")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-bad_name", "true")], + ); } #[test] @@ -234,31 +284,43 @@ mod tests { expected = "attribute name should contain only lowercase ASCII letters, digits, and hyphens" )] fn publisher_tsjs_script_tag_rejects_empty_attribute_name() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("", "true")]); + let _ = tsjs_script_tag_with_attributes(&compile_time_parts(&["gpt"]), &[("", "true")]); } #[test] #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] fn publisher_tsjs_script_tag_rejects_double_quote_in_attribute_value() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "bad\"value")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-safe-name", "bad\"value")], + ); } #[test] #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] fn publisher_tsjs_script_tag_rejects_ampersand_in_attribute_value() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "bad&value")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-safe-name", "bad&value")], + ); } #[test] #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] fn publisher_tsjs_script_tag_rejects_less_than_in_attribute_value() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "badvalue")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-safe-name", "bad>value")], + ); } #[test] @@ -278,36 +340,58 @@ mod tests { #[test] fn tsjs_single_module_script_src_formats_known_module_url_with_hash() { - let src = tsjs_single_module_script_src("creative"); + let parts = compile_time_parts(&["creative"]); + let src = tsjs_single_module_script_src(&parts[0]); assert!( src.starts_with("/static/tsjs=tsjs-creative.min.js?v="), "should use per-module static bundle path" ); assert_sha256_hex_hash(hash_query_value(&src)); + assert_eq!( + src, + format!( + "/static/tsjs=tsjs-creative.min.js?v={}", + trusted_server_js::single_module_hash("creative") + .expect("should have compiled creative in") + ), + "should keep today's URL for a compile-time module" + ); } #[test] - fn tsjs_deferred_script_src_hashes_prebid_shim_and_empties_unknown_module() { - let prebid_src = tsjs_deferred_script_src("prebid"); + fn tsjs_deferred_script_src_hashes_prebid_shim() { + let parts = compile_time_parts(&["prebid"]); + let prebid_src = tsjs_deferred_script_src(&parts[0]); + assert!( prebid_src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="), "prebid shim should be served from the deferred tsjs route" ); assert_sha256_hex_hash(hash_query_value(&prebid_src)); + } + + #[test] + fn tsjs_deferred_script_tag_carries_a_carried_parts_own_hash() { + let part = carried_part("probe", "(() => { window.probe = 1; })()"); + assert_eq!( - tsjs_deferred_script_src("unknown-module"), - "/static/tsjs=tsjs-unknown-module.min.js?v=", - "should document current unknown-module hash behavior" + tsjs_deferred_script_tag(&part), + format!( + "", + part.sha256 + ), + "should version a carried deferred module by its own content hash" ); } #[test] fn tsjs_deferred_script_tag_marks_script_defer() { - let src = tsjs_deferred_script_src("prebid"); + let parts = compile_time_parts(&["prebid"]); + let src = tsjs_deferred_script_src(&parts[0]); assert_eq!( - tsjs_deferred_script_tag("prebid"), + tsjs_deferred_script_tag(&parts[0]), format!(""), "should generate a deferred script tag" ); @@ -324,12 +408,14 @@ mod tests { #[test] fn tsjs_deferred_script_tags_preserves_input_order() { + let parts = compile_time_parts(&["prebid", "creative"]); + assert_eq!( - tsjs_deferred_script_tags(&["prebid", "creative"]), + tsjs_deferred_script_tags(&parts), format!( "{}{}", - tsjs_deferred_script_tag("prebid"), - tsjs_deferred_script_tag("creative") + tsjs_deferred_script_tag(&parts[0]), + tsjs_deferred_script_tag(&parts[1]) ), "should preserve caller-provided deferred module order" ); @@ -353,18 +439,9 @@ mod tests { #[test] fn tsjs_script_src_differs_for_different_module_sets() { assert_ne!( - tsjs_script_src(&["lockr"]), - tsjs_script_src(&["lockr", "permutive"]), + tsjs_script_src(&compile_time_parts(&["lockr"])), + tsjs_script_src(&compile_time_parts(&["lockr", "permutive"])), "should bust the cache when the module set content changes" ); } - - #[test] - fn tsjs_deferred_script_src_has_empty_hash_for_unknown_module() { - assert_eq!( - tsjs_deferred_script_src("does-not-exist"), - "/static/tsjs=tsjs-does-not-exist.min.js?v=", - "should fall back to an empty cache-busting hash for an unknown module" - ); - } } diff --git a/crates/trusted-server-core/src/tsjs_bundle.rs b/crates/trusted-server-core/src/tsjs_bundle.rs new file mode 100644 index 000000000..db66bff04 --- /dev/null +++ b/crates/trusted-server-core/src/tsjs_bundle.rs @@ -0,0 +1,413 @@ +//! Composition of the served tsjs script from module parts. +//! +//! `trusted-server-js` knows only the modules compiled into it. A module a +//! vendor crate carries on its registration is not in that map, so the +//! composition of the served script and its cache-busting hash live here, +//! keyed on content rather than on ids. The byte rule is unchanged from +//! `trusted_server_js::concatenate_modules`: core first, then each part in +//! order, joined by `;\n`, so every existing `?v=` hash is preserved. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use sha2::{Digest as _, Sha256}; + +/// One module of the served script. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JsModulePart { + /// Module id, for example `core` or `lockr`. + pub id: &'static str, + /// The built IIFE. + pub source: &'static str, + /// SHA-256 of `source`, hex encoded. Identifies the content in the memo. + /// + /// The memo in [`compose_hash`] trusts this value rather than hashing + /// `source` again, so a part that declares the wrong hash serves a stale + /// `?v=` under a valid-looking URL. Debug builds and tests check the + /// value against `source`; release builds do not. + pub sha256: &'static str, +} + +impl JsModulePart { + /// Looks up a compile-time module of `trusted-server-js` by id. + /// + /// Returns `None` when no module with that id was compiled in. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::tsjs_bundle::JsModulePart; + /// + /// assert!(JsModulePart::compile_time("core").is_some()); + /// assert!(JsModulePart::compile_time("not-a-module").is_none()); + /// ``` + #[must_use] + pub fn compile_time(id: &'static str) -> Option { + let source = trusted_server_js::module_bundle(id)?; + let sha256 = trusted_server_js::single_module_hash(id)?; + Some(Self { id, source, sha256 }) + } +} + +/// Resolves compile-time parts for a list of ids, dropping unknown ids, as +/// `trusted_server_js` does today. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::tsjs_bundle::compile_time_parts; +/// +/// let parts = compile_time_parts(&["core", "not-a-module"]); +/// +/// assert_eq!(parts.len(), 1); +/// assert_eq!(parts[0].id, "core"); +/// ``` +#[must_use] +pub fn compile_time_parts(ids: &[&'static str]) -> Vec { + ids.iter() + .filter_map(|id| JsModulePart::compile_time(id)) + .collect() +} + +/// Concatenates the parts into the served script. +/// +/// Core comes first (the given `core` part, or the compile-time core when +/// none is given), then the remaining parts in input order. Each id appears +/// once, keeping its first occurrence, and parts are joined by `;\n` so one +/// IIFE cannot run into the next. +/// +/// # Examples +/// +/// ``` +/// use sha2::{Digest as _, Sha256}; +/// use trusted_server_core::tsjs_bundle::{JsModulePart, compose}; +/// +/// fn part(id: &'static str, source: &'static str) -> JsModulePart { +/// let sha256 = Box::leak(hex::encode(Sha256::digest(source)).into_boxed_str()); +/// JsModulePart { id, source, sha256 } +/// } +/// +/// let parts = [ +/// part("example", "(() => { window.example = true; })()"), +/// part("core", "(() => { window.tsjs = {}; })()"), +/// ]; +/// +/// assert_eq!( +/// compose(&parts), +/// "(() => { window.tsjs = {}; })();\n(() => { window.example = true; })()" +/// ); +/// ``` +#[must_use] +pub fn compose(parts: &[JsModulePart]) -> String { + let ordered = ordered(parts); + // Every piece the visit yields has a known length before the walk, so + // reserve the exact byte count once rather than letting the pushes grow + // the buffer. A bundle of a dozen parts is hundreds of kilobytes, and + // growing to that size copies roughly twice the bundle on every request + // that serves it. + let mut size = 0; + visit_parts(&ordered, |part| size += part.len()); + let mut body = String::with_capacity(size); + visit_parts(&ordered, |part| body.push_str(part)); + body +} + +/// SHA-256 of [`compose`]'s output, hex encoded, without materializing it. +/// +/// The result is memoized per ordered set of `(id, sha256)` pairs. Because the +/// key carries each part's content hash, a carried module that keeps its id +/// but changes its source gets a new hash. The memo never evicts, so feed it +/// only sets derived from configuration, never sets derived from request +/// input. +/// +/// The memo can only hit where the process outlives the request, which of the +/// four adapters means the Axum dev server alone, because its `main` builds +/// the router once before serving. Fastly starts a fresh Wasm instance per +/// request, and `edgezero_adapter_cloudflare::run_app` and +/// `edgezero_adapter_spin::run_app` both call `build_app` inside the +/// per-request entry point, so on those three every call is a miss. A miss +/// costs a key vector, two mutex locks and a stored copy of the hash, all +/// beside a SHA-256 over the whole bundle, so the memo is close to free where +/// it cannot hit and removes the hash entirely where it can. +/// +/// # Panics +/// +/// In debug builds, panics when a part's `sha256` is not the SHA-256 of its +/// `source`. Release builds trust the declared hash. +/// +/// # Examples +/// +/// ``` +/// use sha2::{Digest as _, Sha256}; +/// use trusted_server_core::tsjs_bundle::{JsModulePart, compose_hash}; +/// +/// fn part(id: &'static str, source: &'static str) -> JsModulePart { +/// let sha256 = Box::leak(hex::encode(Sha256::digest(source)).into_boxed_str()); +/// JsModulePart { id, source, sha256 } +/// } +/// +/// let core = JsModulePart::compile_time("core").expect("should have compiled core in"); +/// let before = [core, part("example", "(() => { window.example = 1; })()")]; +/// let after = [core, part("example", "(() => { window.example = 2; })()")]; +/// +/// assert_eq!(compose_hash(&before).len(), 64); +/// assert_eq!(compose_hash(&before), compose_hash(&before)); +/// assert_ne!(compose_hash(&before), compose_hash(&after)); +/// ``` +#[must_use] +pub fn compose_hash(parts: &[JsModulePart]) -> String { + for part in parts { + debug_assert_eq!( + part.sha256, + hex::encode(Sha256::digest(part.source)), + "should declare the SHA-256 of its source for part `{}`", + part.id + ); + } + + let ordered = ordered(parts); + let key = ordered + .iter() + .map(|part| (part.id, part.sha256)) + .collect::>(); + if let Some(hash) = lock_cache().get(&key).cloned() { + return hash; + } + + let mut hasher = Sha256::new(); + visit_parts(&ordered, |part| hasher.update(part.as_bytes())); + let hash = hex::encode(hasher.finalize()); + lock_cache().insert(key, hash.clone()); + hash +} + +/// Orders the parts for the served script: core first, then every non-core +/// part in input order, keeping the first occurrence of each id. +fn ordered(parts: &[JsModulePart]) -> Vec { + let mut result = Vec::with_capacity(parts.len() + 1); + + let core = parts + .iter() + .find(|part| part.id == "core") + .copied() + .or_else(|| JsModulePart::compile_time("core")); + if let Some(core) = core { + result.push(core); + } + + for part in parts { + if part.id == "core" { + continue; + } + if result.iter().any(|taken| taken.id == part.id) { + continue; + } + result.push(*part); + } + + result +} + +/// Visits the byte pieces of the served script in order, with `;\n` between +/// consecutive parts. +fn visit_parts(parts: &[JsModulePart], mut visit: F) { + let mut first = true; + for part in parts { + if first { + first = false; + } else { + visit(";\n"); + } + visit(part.source); + } +} + +type HashCache = HashMap, String>; + +fn lock_cache() -> MutexGuard<'static, HashCache> { + static CACHE: OnceLock> = OnceLock::new(); + match CACHE.get_or_init(|| Mutex::new(HashMap::new())).lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) + } + + /// Builds a part whose `sha256` really is the hash of `source`, so no two + /// parts with different content ever share a memo key. + fn part(id: &'static str, source: &'static str) -> JsModulePart { + let sha256 = Box::leak(sha256_hex(source.as_bytes()).into_boxed_str()); + JsModulePart { id, source, sha256 } + } + + #[test] + fn compose_puts_core_first_and_joins_with_a_semicolon_and_newline() { + let parts = [ + part("lockr", "L"), + part("core", "C"), + part("permutive", "P"), + ]; + + assert_eq!( + compose(&parts), + "C;\nL;\nP", + "should order core first and join parts" + ); + } + + #[test] + fn compose_hash_matches_the_compile_time_hash_for_built_in_modules() { + let ids = ["lockr", "permutive"]; + let parts = compile_time_parts(&ids); + + assert_eq!( + compose_hash(&parts), + trusted_server_js::concatenated_hash(&ids), + "should reproduce today's hash for a built-in module set" + ); + assert_eq!( + compose(&parts), + trusted_server_js::concatenate_modules(&ids), + "should reproduce today's bytes for a built-in module set" + ); + } + + #[test] + fn compose_of_no_parts_is_the_compile_time_core_alone() { + assert_eq!( + compose(&[]), + trusted_server_js::concatenate_modules(&[]), + "should serve core alone when no parts are given" + ); + assert_eq!( + compose_hash(&[]), + trusted_server_js::concatenated_hash(&[]), + "should hash core alone when no parts are given" + ); + } + + #[test] + fn compose_matches_every_compile_time_module_set_the_old_api_produces() { + let all = trusted_server_js::all_module_ids(); + let non_core = all + .iter() + .copied() + .filter(|id| *id != "core") + .collect::>(); + let mut cases = vec![all.clone(), non_core.clone()]; + cases.push(non_core.iter().rev().copied().collect()); + cases.push(vec!["core"]); + + for ids in cases { + let parts = compile_time_parts(&ids); + assert_eq!( + compose(&parts), + trusted_server_js::concatenate_modules(&ids), + "should reproduce today's bytes for {ids:?}" + ); + assert_eq!( + compose_hash(&parts), + trusted_server_js::concatenated_hash(&ids), + "should reproduce today's hash for {ids:?}" + ); + } + } + + #[test] + fn compose_hash_changes_when_a_carried_module_changes() { + let before = [part("core", "C"), part("probe", "A")]; + let after = [part("core", "C"), part("probe", "B")]; + + assert_ne!( + compose_hash(&before), + compose_hash(&after), + "should hash carried content" + ); + } + + #[test] + fn compose_keeps_a_duplicate_id_once_using_its_first_occurrence() { + let parts = [ + part("core", "C"), + part("lockr", "L1"), + part("permutive", "P"), + part("lockr", "L2"), + part("core", "C2"), + ]; + + assert_eq!( + compose(&parts), + "C;\nL1;\nP", + "should keep the first occurrence of each id and drop later ones" + ); + } + + #[test] + fn compose_reserves_the_exact_bundle_size_before_writing_it() { + let parts = compile_time_parts(&trusted_server_js::all_module_ids()); + let body = compose(&parts); + + assert_eq!( + body.capacity(), + body.len(), + "should allocate the bundle once at its exact size" + ); + } + + #[test] + fn compose_hash_is_the_hex_sha256_of_compose() { + let parts = [ + part("core", "(() => {})()"), + part("carried", "(() => { window.carried = true; })()"), + part("lockr", "L"), + ]; + + assert_eq!( + compose_hash(&parts), + sha256_hex(compose(&parts).as_bytes()), + "should hash the exact bytes compose produces" + ); + } + + #[test] + #[should_panic(expected = "should declare the SHA-256 of its source for part `lying`")] + fn compose_hash_rejects_a_part_whose_declared_hash_is_wrong() { + let parts = [ + part("core", "C"), + JsModulePart { + id: "lying", + source: "A", + sha256: "not-the-hash-of-a", + }, + ]; + + let _ = compose_hash(&parts); + } + + #[test] + fn compile_time_parts_drops_unknown_ids() { + let parts = compile_time_parts(&["lockr", "not-a-module", "permutive"]); + let ids = parts.iter().map(|part| part.id).collect::>(); + + assert_eq!( + ids, + ["lockr", "permutive"], + "should keep known ids in order and drop unknown ids" + ); + for part in parts { + assert_eq!( + part.sha256, + sha256_hex(part.source.as_bytes()), + "should carry the compile-time hash of {}", + part.id + ); + } + } +} diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index c3d9ff53d..bd938dc6e 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -9,11 +9,24 @@ cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" proxy_secret = "integration_proxy_secret" +# Viceroy does not resolve geolocation for the loopback test client, so the +# request carries no country and the top of the permissions.yaml rules tree +# supplies the baseline the permission model uses. +[geo] +# Viceroy maps the loopback client to US/CA (see viceroy-template.toml), so +# the platform geo provider resolves a real place and the EC lifecycle +# scenarios exercise the US state opt-out machinery, the same posture the +# retired default_country setting pointed at, now through the real lookup. +provider = "platform" + [ec] -passphrase = "integration_ec_passphrase" +provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +[ec.hmac] +passphrase = "integration_ec_passphrase" + [[ec.partners]] name = "Integration Test Partner" source_domain = "inttest.example.com" @@ -31,67 +44,12 @@ enabled = false config_store_id = "app_config" secret_store_id = "secrets" -[integrations.prebid] -enabled = false -timeout_ms = 1000 -debug = false -client_side_bidders = [] - -[integrations.nextjs] -enabled = false -rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] -max_combined_payload_bytes = 10485760 - -[integrations.testlight] -enabled = false -endpoint = "https://testlight.example.com/openrtb2/auction" -timeout_ms = 1200 -rewrite_scripts = true - -[integrations.didomi] -enabled = false -geo_query_parameters = false -sdk_origin = "https://sdk.example.com" -api_origin = "https://api.example.com" - -[integrations.sourcepoint] -enabled = false -rewrite_sdk = true -cdn_origin = "https://cdn.example.com" -cache_ttl_seconds = 3600 - -[integrations.permutive] -enabled = false -organization_id = "" -workspace_id = "" -project_id = "" -api_endpoint = "https://api.example.com" -secure_signals_endpoint = "https://secure-signals.example.com" - -[integrations.lockr] -enabled = false -app_id = "" -api_endpoint = "https://identity.example.com" -sdk_url = "https://identity.example.com/trusted-server.js" -cache_ttl_seconds = 3600 -rewrite_sdk = true - -[integrations.datadome] -enabled = false -sdk_origin = "https://sdk.example.com" -api_origin = "https://api.example.com" -cache_ttl_seconds = 3600 -rewrite_sdk = true - -[integrations.gpt] -enabled = false -gam_attribution_enabled = false -script_url = "https://ads.example.com/gpt.js" -cache_ttl_seconds = 3600 -rewrite_script = true - -[integrations.gpt_diagnostics] -enabled = true +# Only the GPT diagnostics overlay runs, which is what the browser tests need. +# Every other integration used to sit here as an `enabled = false` stub. A +# settings table its type's `provider` does not name now refuses startup, so +# the stubs are gone instead of sitting unselected. +[integration] +provider = ["gpt_diagnostics"] [proxy] certificate_check = false @@ -101,47 +59,31 @@ enabled = false timeout_ms = 2000 allowed_context_keys = [] -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false test_mode = false consent_forwarding = "both" -[auction.providers.pbs-main.notifications] +[demand.pbs_main.notifications] suppress_all = false suppress_seats = [] -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" - -[auction.providers.aps-main.profile_config] account_id = "example-aps-account-id" debug = false allow_script_creatives = false [auction.bidders.example-bidder] -provider = "pbs-main" - -[integrations.google_tag_manager] -enabled = false -container_id = "GTM-EXAMPLE" -upstream_url = "https://tags.example.com" - -[integrations.adserver_mock] -enabled = false -endpoint = "https://adserver.example.com/mediate" -timeout_ms = 1000 - -[integrations.adserver_mock.context_query_params] -example_segments = "segments" +provider = "pbs_main" [debug] ja4_endpoint_enabled = false diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 816dcbfcf..e242e7870 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -4,6 +4,53 @@ [local_server] + # Maps the loopback client to California, United States, so the platform + # geo provider resolves a real place and the permission model applies the + # US/CA rules from the permissions.yaml tree. This replaces the retired + # [geo] default_country lever with the genuine lookup path. + [local_server.geolocation] + format = "inline-toml" + + [local_server.geolocation.addresses."127.0.0.1"] + as_name = "Integration Test" + as_number = 64496 + area_code = 0 + city = "Test City" + conn_speed = "broadband" + conn_type = "wired" + continent = "NA" + country_code = "US" + country_code3 = "USA" + country_name = "United States" + latitude = 0.0 + longitude = 0.0 + metro_code = 0 + postal_code = "00000" + proxy_description = "?" + proxy_type = "?" + region = "CA" + utc_offset = -800 + + [local_server.geolocation.addresses."::1"] + as_name = "Integration Test" + as_number = 64496 + area_code = 0 + city = "Test City" + conn_speed = "broadband" + conn_type = "wired" + continent = "NA" + country_code = "US" + country_code3 = "USA" + country_name = "United States" + latitude = 0.0 + longitude = 0.0 + metro_code = 0 + postal_code = "00000" + proxy_description = "?" + proxy_type = "?" + region = "CA" + utc_offset = -800 + [local_server.backends] [local_server.kv_stores] diff --git a/crates/trusted-server-integration-tests/tests/documentation_snippets.rs b/crates/trusted-server-integration-tests/tests/documentation_snippets.rs index d9c36d233..0e56dbc11 100644 --- a/crates/trusted-server-integration-tests/tests/documentation_snippets.rs +++ b/crates/trusted-server-integration-tests/tests/documentation_snippets.rs @@ -38,18 +38,24 @@ fn integration_guide_runtime_services_fixture_compiles() { let core_path = repository_root.join("crates/trusted-server-core"); let workspace_manifest = fs::read_to_string(repository_root.join("Cargo.toml")) .expect("should read the workspace manifest"); - let error_stack_requirement = workspace_manifest - .lines() - .find_map(|line| { - let assignment = line.trim().strip_prefix("error-stack")?.trim_start(); - let value = assignment.strip_prefix('=')?; - let start = value.find('"')? + 1; - let end = start + value[start..].find('"')?; - Some(value[start..end].to_owned()) - }) - .expect("should find the workspace error-stack requirement"); + let requirement_of = |crate_name: &str| { + workspace_manifest + .lines() + .find_map(|line| { + let assignment = line.trim().strip_prefix(crate_name)?.trim_start(); + let value = assignment.strip_prefix('=')?; + let start = value.find('"')? + 1; + let end = start + value[start..].find('"')?; + Some(value[start..end].to_owned()) + }) + .unwrap_or_else(|| panic!("should find the workspace {crate_name} requirement")) + }; + let error_stack_requirement = requirement_of("error-stack"); + // The documented fixture implements `PlatformGeo`, whose `lookup` is + // asynchronous, so it carries the same attribute the trait does. + let async_trait_requirement = requirement_of("async-trait"); let manifest = format!( - "[package]\nname = \"documentation-snippet\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[workspace]\n\n[dependencies]\nerror-stack = \"{error_stack_requirement}\"\ntrusted-server-core = {{ path = {:?} }}\n", + "[package]\nname = \"documentation-snippet\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[workspace]\n\n[dependencies]\nasync-trait = \"{async_trait_requirement}\"\nerror-stack = \"{error_stack_requirement}\"\ntrusted-server-core = {{ path = {:?} }}\n", core_path ); fs::write(fixture_root.join("Cargo.toml"), manifest) diff --git a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs index c1bbafe8d..a7a45f2f6 100644 --- a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs +++ b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs @@ -445,9 +445,10 @@ pub enum EcScenario { /// returns the scoped UID. FullLifecycle, - /// Consent withdrawal: GPC header triggers EC cookie deletion for a - /// seeded EC in the default US-state test geo. - ConsentWithdrawal, + /// Opt-out suppression: a GPC header suppresses use of a seeded EC + /// (identify answers 403) without expiring the cookie, in the default + /// US-state test geo. Opt-outs are never destructive. + OptOutSuppression, /// Identify without EC cookie returns 204. IdentifyWithoutEc, @@ -470,7 +471,7 @@ impl EcScenario { pub fn all() -> Vec { vec![ Self::FullLifecycle, - Self::ConsentWithdrawal, + Self::OptOutSuppression, Self::IdentifyWithoutEc, Self::IdentifyConsentDenied, Self::ConcurrentPartnerSyncs, @@ -489,7 +490,7 @@ impl EcScenario { pub fn run(&self, base_url: &str) -> TestResult<()> { match self { Self::FullLifecycle => ec_full_lifecycle(base_url), - Self::ConsentWithdrawal => ec_consent_withdrawal(base_url), + Self::OptOutSuppression => ec_opt_out_suppression(base_url), Self::IdentifyWithoutEc => ec_identify_without_ec(base_url), Self::IdentifyConsentDenied => ec_identify_consent_denied(base_url), Self::ConcurrentPartnerSyncs => ec_concurrent_partner_syncs(base_url), @@ -577,37 +578,48 @@ fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { } /// Consent withdrawal: GPC header clears EC cookie. -fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> { +fn ec_opt_out_suppression(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); let seeded_ec_id = seeded_ec_id('b', "test02"); let ec_id = use_seeded_ec(&client, &seeded_ec_id); - log::info!("EC consent withdrawal: using seeded EC = {ec_id}"); + log::info!("EC opt-out suppression: using seeded EC = {ec_id}"); - // GPC overrides the allow cookie in US-CA, so this is an explicit - // withdrawal and must expire the EC cookie. + // GPC is a US-style opt-out. It suppresses use of the identifier for the + // request but is never destructive: the cookie is not expired and no + // tombstone is written, so lifting the opt-out restores the identity. let resp = client.get_with_headers("/", &[("sec-gpc", "1")])?; - - if !is_ec_cookie_expired(&resp) { + if is_ec_cookie_expired(&resp) { return Err(Report::new(TestError::UnexpectedContent) - .attach("consent withdrawal should expire ts-ec cookie (expected Max-Age=0)")); + .attach("an opt-out must suppress without expiring the ts-ec cookie")); } - if client.ec_cookie_value().is_some() { + if client.ec_cookie_value().is_none() { return Err(Report::new(TestError::UnexpectedContent) - .attach("client should stop tracking ts-ec after explicit withdrawal")); + .attach("the client should keep tracking ts-ec through an opt-out")); } - // 3. With consent still granted and the EC cookie revoked, identify should - // now report no EC present. - let resp = identify(&client, INTTEST_API_TOKEN)?; - assert_status(&resp, 204).attach("identify should return 204 after cookie revocation")?; - - // 4. With GPC still asserted, identify should reflect consent denial. + // With GPC asserted, identify reflects the suppressed permissions. let resp = identify_with_headers(&client, INTTEST_API_TOKEN, &[("sec-gpc", "1")])?; assert_status(&resp, 403) - .attach("identify with GPC should return 403 after consent withdrawal")?; + .attach("identify with GPC should return 403 while the opt-out is asserted")?; + + // Without GPC the identity is usable again: the cookie still carries the + // identifier, so identify answers 200 with consent ok. No row was seeded + // for this identifier, so there is no enrichment, and the absence of a + // 403 proves the opt-out destroyed nothing. + let resp = identify(&client, INTTEST_API_TOKEN)?; + let body = assert_json_response(resp, 200)?; + if body.get("consent").and_then(|v| v.as_str()) != Some("ok") { + return Err(Report::new(TestError::UnexpectedContent).attach(format!( + "identify without GPC should report consent ok, got {body}" + ))); + } + if body.get("uid").is_some() { + return Err(Report::new(TestError::UnexpectedContent) + .attach("no partner UID was seeded, so identify should carry no enrichment")); + } - log::info!("EC consent withdrawal: PASSED"); + log::info!("EC opt-out suppression: PASSED"); Ok(()) } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index c466e2036..1f53c7603 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -46,7 +46,13 @@ fn test_settings() -> Settings { proxy_secret = "parity-test-proxy-secret" [ec] + provider = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse parity test settings") diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 2bfee01b1..9991e5506 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -11,7 +11,7 @@ * The prebid integration builds here as the tsjs shim only — Prebid.js itself * is never bundled into tsjs. Use build-prebid-external.mjs to generate the * pure Prebid.js external bundle (core + adapters + user ID modules) that the - * shim requires at runtime via integrations.prebid.external_bundle_url. + * shim requires at runtime via integration.prebid.external_bundle_url. */ import fs from 'node:fs'; diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index a02c5bf60..5600f7cc5 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -5,7 +5,7 @@ * * Unlike build-all.mjs, this script is intended to run outside the Cargo build. * It produces an immutable bundle and manifest that can be hosted on an asset - * CDN, then referenced by integrations.prebid.external_bundle_url. + * CDN, then referenced by integration.prebid.external_bundle_url. */ import crypto from 'node:crypto'; diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index b2c4e41e1..2678a879f 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -13,6 +13,7 @@ import { log } from './log'; import { setConfig, getConfig } from './config'; import { requestAds } from './request'; import { installQueue } from './queue'; +import { installPermissions } from './permissions'; const VERSION = '0.1.0'; @@ -42,6 +43,10 @@ api.requestAds = requestAds; // instead of throwing. Injected scripts overwrite these wholesale. api.adSlots ??= []; api.bids ??= {}; +// The edge also injects the request's resolved permission state, either at head +// open (inline mode) or at the seam (shared-template mode). An accessor +// observes the seam's plain assignment so page code can await it either way. +installPermissions(api); // Point global tsjs w.tsjs = api; @@ -68,5 +73,6 @@ log.info('tsjs initialized', { 'addAdUnits', 'renderAdUnit', 'renderAllAdUnits', + 'whenPermissions', ], }); diff --git a/crates/trusted-server-js/lib/src/core/permissions.ts b/crates/trusted-server-js/lib/src/core/permissions.ts new file mode 100644 index 000000000..19929f2e5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/permissions.ts @@ -0,0 +1,85 @@ +// Permission state the edge injects into the page, and the promise page code +// awaits so it can read that state whichever order the injection arrives in. +import { log } from './log'; +import type { PermissionsSnapshot, TsjsApi } from './types'; + +function isSnapshot(value: unknown): value is PermissionsSnapshot { + return typeof value === 'object' && value !== null; +} + +// Page code reads `set` and `tdls` without checking either exists, so both are +// arrays whatever arrived. The edge always sends both, and a page assigning a +// snapshot by hand, or an older edge, may not. +function normalize(snapshot: PermissionsSnapshot): PermissionsSnapshot { + return { + ...snapshot, + set: Array.isArray(snapshot.set) ? snapshot.set : [], + tdls: Array.isArray(snapshot.tdls) ? snapshot.tdls : [], + }; +} + +/** + * Install the `permissions` accessor and `whenPermissions()` on the API object. + * + * The edge injects `window.tsjs.permissions` either before this bundle runs + * (inline mode, at head open) or after it has initialized (shared-template + * mode, as a plain assignment at the `` seam), and on a page with no + * seam it never arrives at all. An accessor observes the plain assignment, so + * the promise resolves in every one of those orders. + */ +export function installPermissions(api: TsjsApi): void { + // A value already on the API object came from the head-open injection, so it + // is the current value; otherwise page code must still read a defined value. + const injected = api.permissions; + let current: PermissionsSnapshot = isSnapshot(injected) + ? normalize(injected) + : { set: [], tdls: [] }; + let settled = false; + let resolvePending: (snapshot: PermissionsSnapshot) => void = () => {}; + const pending = new Promise((resolve) => { + resolvePending = resolve; + }); + + function settle(snapshot: PermissionsSnapshot): void { + if (settled) return; + settled = true; + resolvePending(snapshot); + } + + Object.defineProperty(api, 'permissions', { + get(): PermissionsSnapshot { + return current; + }, + set(value: PermissionsSnapshot) { + current = isSnapshot(value) ? normalize(value) : { set: [], tdls: [] }; + log.debug('permissions: received', current); + settle(current); + }, + enumerable: true, + configurable: true, + }); + + if (isSnapshot(injected)) { + log.debug('permissions: present at initialization', injected); + settle(current); + } else if (typeof document !== 'undefined') { + // The seam sits at ``, so anything it was going to assign has run by + // the time the document is parsed. Resolve with whatever the current value + // is rather than leaving page code waiting on a page that has no seam. A + // bundle that initializes after parsing has already missed any seam, so it + // resolves at once instead of waiting for an event that will never fire. + if (document.readyState === 'loading') { + document.addEventListener( + 'DOMContentLoaded', + () => { + settle(current); + }, + { once: true } + ); + } else { + settle(current); + } + } + + api.whenPermissions = () => pending; +} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index e49b66146..25204af3f 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -365,6 +365,24 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } +/** + * Permission state the server resolved for this request. + * + * The names in `set` are IAB Privacy Taxonomy Data Use keys, as resolved by the + * server for this request. + * + * `tdls` are the terms documents the data for this request is available under, + * as declared by the permission signal providers the deployment runs, in the + * order they were asked. Each entry addresses a published document a person can + * read. An empty list says no terms were declared, which is not the same as + * terms that permit anything, so page code that needs a basis and finds none + * has none. + */ +export interface PermissionsSnapshot { + set: string[]; + tdls: string[]; +} + export type FirstImpressionOwner = 'publisher' | 'trusted_server'; export type FirstImpressionPhase = 'auctioning' | 'delivery_pending' | 'requested' | 'rendered'; @@ -427,6 +445,20 @@ export interface TsjsApi { adSlots?: AuctionSlot[]; /** Winning bid targeting data injected before . */ bids?: Record; + /** + * Permission state resolved by the server for this request, injected at head + * open in inline mode or at the body seam in shared-template mode. + */ + permissions?: PermissionsSnapshot; + /** + * Resolves with the permission state once it arrives. + * + * It resolves straight away when the state was already present at + * initialization, on the first assignment when the body seam makes one, and + * on `DOMContentLoaded` with whatever the current value is when no assignment + * arrives at all. Every later call returns the same resolved promise. + */ + whenPermissions?(): Promise; /** * Bounded client-side Prebid APS renderer capabilities keyed by Prebid's generated * `hb_adid`. The Universal Creative bridge consumes each entry at most once. diff --git a/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts new file mode 100644 index 000000000..b7f0def0d --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts @@ -0,0 +1,95 @@ +// Demonstration client for the client-cycle Edge Cookie provider (`client_fixed`). +// +// Client and server share one fixed, known word. When the resolved marker is +// absent, this posts that word to the resolve endpoint. With the `client_fixed` +// provider selected, the server verifies the word and, on a match, persists the +// identity-graph row and sets the coded form of the word (cfix~an-ec) as an +// HttpOnly Edge Cookie on the +// response, together with a non-HttpOnly marker cookie. The Edge Cookie itself +// is HttpOnly, so this script can never see it; the marker is what tells it a +// resolve already succeeded, so it does not post again on every page view. +// +// The value is verifiable precisely because it is a known constant, which is the +// point of the demo. It is useless in production, because a fixed value is not an +// identity and every client posts the same word. For demonstration and testing +// only. A real client-cycle provider posts and verifies a real payload (for +// example an OWID signature) instead of a shared constant. +import { log } from '../../core/log'; + +const RESOLVE_ENDPOINT = '/_ts/api/v1/ec/resolve'; + +// The non-HttpOnly companion the server sets alongside the Edge Cookie. Must +// match COOKIE_TS_EC_RESOLVED in crates/trusted-server-core/src/constants.rs; +// a Rust test asserts the two stay in sync. +const MARKER_COOKIE_NAME = 'ts-ecr'; + +// The fixed, known word shared with the server. Must match EXPECTED_VALUE in +// crates/trusted-server-core/src/ec/provider.rs; a Rust test asserts the two +// stay in sync. +const FIXED_WORD = 'an-ec'; + +// The permission this provider requires, the same declaration the server-side +// provider makes in `required_permissions` (crates/trusted-server-core/src/ec/ +// provider.rs). A page module is treated like any other provider: it declares +// what it requires and checks that against the resolved state the server +// hands the page before it does anything. The server enforces the same gate on +// the resolve endpoint, so this check is the page's half of one decision, not +// a substitute for the server's. A Rust test asserts the two stay in sync. +const REQUIRED_PERMISSION = 'necessary.operations.storage'; + +// Waits for the resolved permission state the edge injects into the page +// (`window.tsjs.permissions`, via `tsjs.whenPermissions()`) and returns +// whether the permission this module requires is set. With no permission state +// on the page there is nothing to check against, so the answer is no. +export async function requiredPermissionIsSet(): Promise { + const whenPermissions = window.tsjs?.whenPermissions; + if (typeof whenPermissions !== 'function') { + log.warn('ec client_fixed: no permission state on the page, not posting'); + return false; + } + const snapshot = await whenPermissions(); + return Array.isArray(snapshot?.set) && snapshot.set.includes(REQUIRED_PERMISSION); +} + +// Returns true when the resolved marker is present in `cookieString`. The Edge +// Cookie itself is HttpOnly and never appears in `document.cookie`, so the +// marker is the only signal the page has. +export function hasResolvedMarker(cookieString: string): boolean { + return cookieString.split(';').some((part) => part.trim().startsWith(`${MARKER_COOKIE_NAME}=`)); +} + +// Posts the fixed known word to the resolve endpoint when no resolved marker is +// present and the required permission is set. Returns the word posted, or null +// when nothing was sent or the post failed (a resolve already succeeded, the +// required permission is not set, the environment lacks `document`/`fetch`, or +// the request threw). +export async function resolveEdgeCookie(): Promise { + if (typeof document === 'undefined' || typeof fetch !== 'function') { + return null; + } + if (hasResolvedMarker(document.cookie)) { + return null; + } + if (!(await requiredPermissionIsSet())) { + log.info('ec client_fixed: required permission not set, not posting'); + return null; + } + + try { + await fetch(RESOLVE_ENDPOINT, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'text/plain' }, + body: FIXED_WORD, + }); + log.info('ec client_fixed: posted the known word to the resolve endpoint'); + return FIXED_WORD; + } catch (err) { + log.warn('ec client_fixed: resolve request failed', err); + return null; + } +} + +if (typeof window !== 'undefined') { + void resolveEdgeCookie(); +} diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index a643080a3..7afe1278f 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -45,8 +45,8 @@ type PbjsGlobal = typeof _pbjsDefault & { }; // Prebid.js itself is NOT bundled into this module. It is served as the -// external bundle configured via `integrations.prebid.external_bundle_url` -// (required whenever the prebid integration is enabled) and owns the +// external bundle configured via `integration.prebid.external_bundle_url` +// (required whenever the prebid integration runs) and owns the // `window.pbjs` global. The Rust head injector emits a stub // (`window.pbjs = window.pbjs || {que:[],cmd:[]}`) before any script runs and // Prebid.js installs its API onto that same object, so capturing the reference @@ -1907,7 +1907,7 @@ function installApsBidResponseRegistry(): void { export function installPrebidNpm(config?: Partial): typeof pbjs { // The prebid integration requires the external Prebid.js bundle - // (integrations.prebid.external_bundle_url). When it failed to load (network + // (integration.prebid.external_bundle_url). When it failed to load (network // error, SRI mismatch) window.pbjs is still the head-injected stub with no // API — installing the adapter is impossible, so bail out loudly. if (!hasPrebidJsApi()) { @@ -2523,7 +2523,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter in the external ` + 'Prebid bundle. Add its exact Prebid module stem to ' + - '[integrations.prebid.bundle.modules].bidder in trusted-server.toml and ' + + '[integration.prebid.bundle.modules].bidder in trusted-server.toml and ' + 'rebuild it with `ts prebid bundle`.' ); } diff --git a/crates/trusted-server-js/lib/test/core/permissions.test.ts b/crates/trusted-server-js/lib/test/core/permissions.test.ts new file mode 100644 index 000000000..fe0ece6e5 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/permissions.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import type { PermissionsSnapshot, TsjsApi } from '../../src/core/types'; + +describe('core/permissions', () => { + // The bundle is injected at head open, so the document is still parsing + // when core initializes. Tests set the state explicitly because the + // fallback path depends on it. + function setReadyState(state: DocumentReadyState): void { + Object.defineProperty(document, 'readyState', { + value: state, + configurable: true, + }); + } + + beforeEach(async () => { + await vi.resetModules(); + document.body.innerHTML = ''; + delete window.tsjs; + setReadyState('loading'); + }); + + it('keeps permissions injected before the bundle loads and resolves with them', async () => { + const injected: PermissionsSnapshot = { + set: ['necessary.operations'], + tdls: ['https://terms.example.com/marketing/2.txt'], + }; + window.tsjs = { permissions: injected } as TsjsApi; + + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + expect(api.permissions).toEqual(injected); + await expect(api.whenPermissions!()).resolves.toEqual(injected); + }); + + it('resolves on the body seam assignment and reads the value back', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + const settled = api.whenPermissions!(); + api.permissions = { set: ['marketing.advertising.serving'], tdls: [] }; + + await expect(settled).resolves.toEqual({ + set: ['marketing.advertising.serving'], + tdls: [], + }); + expect(api.permissions).toEqual({ + set: ['marketing.advertising.serving'], + tdls: [], + }); + }); + + it('falls back to the empty default when no assignment arrives before DOMContentLoaded', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + const settled = api.whenPermissions!(); + document.dispatchEvent(new Event('DOMContentLoaded')); + + await expect(settled).resolves.toEqual({ set: [], tdls: [] }); + }); + + it('resolves at once when the document has already been parsed', async () => { + // A bundle initializing after parsing has missed any seam, so waiting for + // DOMContentLoaded would wait forever. + setReadyState('interactive'); + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + await expect(api.whenPermissions!()).resolves.toEqual({ set: [], tdls: [] }); + }); + + it('returns the same resolved value from every later call', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { set: ['analytics.reporting'], tdls: [] }; + const first = await api.whenPermissions!(); + const second = await api.whenPermissions!(); + + expect(second).toBe(first); + expect(second).toEqual({ set: ['analytics.reporting'], tdls: [] }); + }); + + it('gives page code both lists when the edge sent only the permissions', async () => { + // An older edge, or a page assigning a snapshot by hand, sends no terms. + // Page code still reads `tdls` without checking it exists. + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { set: ['analytics.reporting'] } as PermissionsSnapshot; + + expect(api.permissions.tdls).toEqual([]); + await expect(api.whenPermissions!()).resolves.toEqual({ + set: ['analytics.reporting'], + tdls: [], + }); + }); + + it('carries the terms the edge declared for the request', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { + set: ['necessary.operations.storage'], + tdls: ['https://terms.example.com/marketing/2.txt'], + }; + + expect(api.permissions.tdls).toEqual(['https://terms.example.com/marketing/2.txt']); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/ec_client_fixed/index.test.ts b/crates/trusted-server-js/lib/test/integrations/ec_client_fixed/index.test.ts new file mode 100644 index 000000000..97f59bebf --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ec_client_fixed/index.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ORIGINAL_FETCH = global.fetch; + +async function importModule() { + vi.resetModules(); + return import('../../../src/integrations/ec_client_fixed/index'); +} + +function clearResolvedMarker() { + document.cookie = 'ts-ecr=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; +} + +describe('ec_client_fixed', () => { + // The page state the edge injects, as core exposes it. The default grants + // the permission this module requires so the existing posting tests hold. + function setPageState(set: string[] | undefined): void { + if (set === undefined) { + delete window.tsjs; + return; + } + window.tsjs = { + whenPermissions: () => Promise.resolve({ set }), + } as unknown as NonNullable; + } + + beforeEach(() => { + clearResolvedMarker(); + setPageState(['necessary.operations.storage']); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + clearResolvedMarker(); + delete window.tsjs; + vi.resetModules(); + }); + + it('does not post when the required permission is not set', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + setPageState(['advertising_marketing.first_party.contextual']); + const { resolveEdgeCookie, requiredPermissionIsSet } = await importModule(); + fetchMock.mockClear(); + + await expect(requiredPermissionIsSet()).resolves.toBe(false); + await expect(resolveEdgeCookie()).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not post when the page carries no permission state at all', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + setPageState(undefined); + const { resolveEdgeCookie } = await importModule(); + fetchMock.mockClear(); + + await expect(resolveEdgeCookie()).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('waits for permission state that arrives after the module runs', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + let resolveState: (snapshot: { set: string[] }) => void = () => {}; + window.tsjs = { + whenPermissions: () => + new Promise<{ set: string[] }>((resolve) => { + resolveState = resolve; + }), + } as unknown as NonNullable; + const { resolveEdgeCookie } = await importModule(); + fetchMock.mockClear(); + + const pending = resolveEdgeCookie(); + expect(fetchMock).not.toHaveBeenCalled(); + resolveState({ set: ['necessary.operations.storage'] }); + + await expect(pending).resolves.toBe('an-ec'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('detects the resolved-marker cookie presence', async () => { + const { hasResolvedMarker } = await importModule(); + expect(hasResolvedMarker('a=1; ts-ecr=1; b=2')).toBe(true); + expect(hasResolvedMarker('first-party=1; b=2')).toBe(false); + // The Edge Cookie itself is HttpOnly and never visible here, so its name + // must not satisfy the marker check. + expect(hasResolvedMarker('ts-ec=abc')).toBe(false); + expect(hasResolvedMarker('')).toBe(false); + }); + + it('posts the fixed known word to the resolve endpoint when no marker is present', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + const { resolveEdgeCookie } = await importModule(); + // Ignore the import-time auto-run; assert on an explicit call. + fetchMock.mockClear(); + + const value = await resolveEdgeCookie(); + + expect(value).toBe('an-ec'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + '/_ts/api/v1/ec/resolve', + expect.objectContaining({ method: 'POST', body: 'an-ec' }) + ); + }); + + it('does not post when the resolved marker is already present', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + document.cookie = 'ts-ecr=1'; + const { resolveEdgeCookie } = await importModule(); + fetchMock.mockClear(); + + const value = await resolveEdgeCookie(); + + expect(value).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index bee0af5d0..9969b78a9 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -7092,7 +7092,7 @@ describe('prebid/client-side bidders', () => { // not the internal build script. const pointsAtBundleConfig = errorCalls.some((args) => args.some( - (a) => typeof a === 'string' && a.includes('[integrations.prebid.bundle.modules].bidder') + (a) => typeof a === 'string' && a.includes('[integration.prebid.bundle.modules].bidder') ) ); expect(pointsAtBundleConfig).toBe(true); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 422085179..11bcff0f5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -111,6 +111,8 @@ export default withMermaid( items: [ { text: 'Edge Cookies', link: '/guide/edge-cookies' }, { text: 'EC Setup', link: '/guide/ec-setup-guide' }, + { text: 'Permission Model', link: '/guide/permission-model' }, + { text: 'Permission Signals', link: '/guide/permission-signals' }, { text: 'GDPR Compliance', link: '/guide/gdpr-compliance' }, { text: 'Ad Serving', link: '/guide/ad-serving' }, { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 6f8eeeaa1..e4d5e02bf 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -173,7 +173,7 @@ settings; sanitization is opt-in and rewriting is enabled by default. Configured provider IDs appear in response metadata and provider responses. Consumers that previously matched the literal provider name `prebid` must use -the configured provider ID, such as `pbs-main`. +the configured demand source name, such as `pbs_main`. **Contract:** Auth: none. The buffered JSON body is limited to 256 KiB. A successful auction and an intentional no-bid both return `200` JSON; disabling @@ -340,6 +340,18 @@ or CORS headers. It is Fastly-only and requires the EC KV store. --- +### POST /\_ts/api/v1/ec/resolve + +Resolve endpoint for client-side Edge Cookie providers. The page posts a value that the provider verifies and creates the Edge Cookie value. Used only when a client-side provider is selected (for example the `client_fixed` demonstration provider). Server-side providers such as HMAC do not use it. + +**Auth:** None, but the request must carry an `Origin` on the publisher's own domain (a foreign or missing `Origin` answers `403`). This is a first-party POST from the page. The provider is responsible for verifying the posted value before trusting it. + +**Request Body:** the provider's value, opaque to the core. For `client_fixed` this is the fixed known word sent as `text/plain`. + +**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the identifier is written to the identity graph first, then the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) together with the `ts-ecr` marker cookie the page script can read, and the status is `200`. When the gate is closed, no client-side provider is configured, no identity graph is available, or the provider produces no identifier, the response is `204` with no cookie. Rejections: `403` for a missing or foreign `Origin`, `415` for a content type other than `text/plain` or `application/json`, `413` for an oversized body, `400` when the created identifier is outside the identifier bounds, `409` when the request already carries a different identity, and `503` when the identity-graph write fails. Every response the handler builds carries `Cache-Control: no-store`. + +--- + ### GET /first-party/proxy Unified proxy for resources referenced by creatives (images, scripts, CSS, etc.). @@ -779,13 +791,13 @@ The examples below use fictional IDs and values only. ### GET /\_ts/admin/ec/`{id}` -Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format. The bare route uses the request's `ts-ec` cookie. +Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID created by the provider this deployment selects, such as the built-in HMAC provider's `hmac~{64 hex}.{6 alphanumeric}` form. The built-in HMAC provider also still reads the bare legacy `{64 hex}.{6 alphanumeric}` form, and a deployment with no provider selected accepts both of those forms. The bare route uses the request's `ts-ec` cookie. This lookup is implemented only by the Fastly adapter because the identity graph is stored in Fastly KV. Other adapters return `501 Not Implemented`. **Response fields:** -- `ec_id`, `store`, and `generation` identify the raw KV lookup. +- `ec_id` is the EC ID as requested, and `kv_key` is the identity-graph key the record was read from. The key is `ec_id` in the normalized form the identity graph stores, which is the same string as `ec_id` for an identifier the built-in HMAC provider issued. `store` and `generation` identify the raw KV lookup. - `entry` preserves the stored JSON shape, including unknown and legacy fields. Derived `created_iso` and `consent.updated_iso` fields are added only when absent. - `metadata` preserves the stored metadata JSON shape. - `tombstone` reports whether consent has been withdrawn. It is absent when the entry body cannot be parsed as JSON or deserialized as the typed EC schema. @@ -963,7 +975,7 @@ compiled integration registry, not an arbitrary filename lookup. ``` **Module Selection:** -All integration modules are built at compile time. At runtime, the server concatenates only the modules whose integrations are enabled in `trusted-server.toml` (or env vars). No rebuild is required to change the module set. +All integration modules are built at compile time. At runtime, the server concatenates only the modules of the integrations `[integration] provider` names in `trusted-server.toml`. No rebuild is required to change the module set. --- @@ -1019,7 +1031,7 @@ rewriter, injector, post-processor, request filter, or auction mediator. | `permutive` | `enabled=true` | `POST /integrations/permutive/secure-signal/*` | | `permutive` | `enabled=true` | `POST /integrations/permutive/sync/*` | | `prebid` | `enabled=true;script_patterns=config-derived` | `GET /integrations/prebid/bundle.js` | -| `prebid` | `enabled=true;script_patterns=config-derived` | `GET ` | +| `prebid` | `enabled=true;script_patterns=config-derived` | `GET ` | | `sourcepoint` | `enabled=true` | `GET /integrations/sourcepoint/cdn/*` | | `sourcepoint` | `enabled=true` | `HEAD /integrations/sourcepoint/cdn/*` | | `sourcepoint` | `enabled=true` | `OPTIONS /integrations/sourcepoint/cdn/*` | @@ -1043,7 +1055,7 @@ available when a deployment needs either. | Didomi consent | `GET` or `POST` under the configured prefix (default `/integrations/didomi/consent/*`); path selects SDK or API origin; query and bounded POST body forwarded | Upstream status/body preserved; SDK responses receive the integration's CORS headers; API responses retain selected upstream headers; no local cache policy | `curl -i https://edge.example.com/integrations/didomi/consent/loader.js` | | GTM/gtag scripts | `GET` the generated `gtm.js`, `gtag.js`, or `gtag/js` paths; query forwarded or configured container ID supplied; successful script is rewritten | Non-success upstream status preserved; rewritten scripts use `cache_max_age`; oversized rewritten upstream bodies use shared integration errors | `curl -i 'https://edge.example.com/integrations/google_tag_manager/gtm.js?id=GTM-XXXX'` | | Google collect | `GET` or `POST` the generated `collect` or `g/collect` paths; query, selected headers, and bounded body proxy to the configured Google origin | Malformed `Content-Length` returns `400`; body over `max_beacon_body_size` returns `413`; stream-read failure returns `502`; upstream response otherwise preserved | Browser beacon; body schema belongs to Google Analytics | -| JS asset proxy | `GET` each configured `[[integrations.js_asset_proxy.assets]]` path whose `proxy = "enabled"`; the exact `origin_url` is fetched and served first-party | Upstream failures use shared integration errors; successful responses honor the per-asset or integration `cache_ttl_seconds`; `blocked` assets register no route and strip matching tags | Path is operator-configured, for example `curl -i https://edge.example.com/js/vendor-tag.js` | +| JS asset proxy | `GET` each configured `[[integration.js_asset_proxy.assets]]` path whose `proxy = "enabled"`; the exact `origin_url` is fetched and served first-party | Upstream failures use shared integration errors; successful responses honor the per-asset or integration `cache_ttl_seconds`; `blocked` assets register no route and strip matching tags | Path is operator-configured, for example `curl -i https://edge.example.com/js/vendor-tag.js` | | GPT | `GET` `/script`, `/pagead/*`, or `/tag/*`; path/query proxy to the configured GPT origins and script content can be rewritten | Upstream status is preserved; successful scripts/assets apply integration cache rules; selected upstream CORS is preserved | `curl -i https://edge.example.com/integrations/gpt/script` | | Lockr SDK | `GET /integrations/lockr/sdk`; no body; fetches and returns the configured SDK as JavaScript | Successful SDK uses `cache_ttl_seconds`; upstream/transport failures follow integration mapping; no added CORS policy | `curl -i https://edge.example.com/integrations/lockr/sdk` | | Lockr API | `GET` or `POST /integrations/lockr/api/*`; path, query, selected headers, and bounded body proxy to `api_endpoint`; publisher credentials are stripped | Upstream status/body preserved; no local cache/CORS policy | Payload is Lockr-specific; use the SDK for normal calls | diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index a6145f80d..f54d7109c 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -108,12 +108,13 @@ unavailable capabilities fail through explicit unavailable implementations. External configuration via `trusted-server.toml` allows deployment-time customization without code changes. -Server-side auctions are configuration-first. `[auction.providers.]` declares +Server-side auctions are configuration-first. `[demand] provider` selects the +demand sources and each `[demand.]` table provider instances and `[auction.bidders.]` maps browser-visible bidders to exactly one provider. Startup compiles these maps into one immutable `AuctionPlan` shared by orchestration and integration registration. Provider IDs remain distinct from upstream returned seats and browser delivery bidder codes. -The optional mediator is selected separately by `[auction].mediator`. +The optional ad server is selected separately by `[adserver] provider`. ### Consent-Aware Design diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 2fc33796a..2b54e5167 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -10,13 +10,13 @@ Key capabilities: - **Parallel execution** — Bid requests to all providers launch concurrently using Fastly's `select()` API - **Strategy-based winner selection** — Automatic strategy detection based on configuration -- **Mediator support** — Optional external mediator for final winner selection and unified floor pricing +- **Ad server support**. An optional external ad server makes the final winner selection and applies unified floor pricing - **Provider abstraction** — Pluggable provider interface for adding new demand sources - **Creative processing** — Winning creatives are rewritten to first-party proxy URLs by default, with opt-in sanitization ## System Flow (Prebid + APS) -The following diagram shows the full auction flow when both Prebid and APS providers are configured with a mediator: +The following diagram shows the full auction flow when both a Prebid Server and an APS demand source are configured with an ad server: ```mermaid %%{init: { @@ -56,7 +56,7 @@ sequenceDiagram participant Orch as Orchestrator participant APS as APS Provider participant Prebid as Prebid Provider - participant Med as AdServer Mediator + participant Med as Ad Server participant Mock as Mocktioneer %% === Auction Request Initiation === @@ -78,10 +78,10 @@ sequenceDiagram activate TS activate Orch TS->>Orch: orchestrator.run_auction() - Orch->>Orch: Detect strategy
mediator? parallel_mediation : parallel_only + Orch->>Orch: Detect strategy
ad server? parallel_adserver : parallel_only deactivate TS - Note over Orch: Strategy determined by config:
[auction]
mediator = "adserver_mock" → parallel_mediation
No mediator → parallel_only + Note over Orch: Strategy determined by config:
[adserver]
provider = "adserver_mock" → parallel_adserver
No [adserver] → parallel_only end %% === Parallel Provider Execution === @@ -118,19 +118,19 @@ sequenceDiagram end %% === Winner Selection Strategy === - alt Mediator Configured (parallel_mediation) + alt Ad Server Configured (parallel_adserver) rect rgb(236,253,245) - Note over Client,Mock: Mediation Flow + Note over Client,Mock: Ad Server Flow activate Med - Orch->>Med: POST /adserver/mediate
Decoded-price bids for final selection + Orch->>Med: POST the ad server endpoint
Decoded-price bids for final selection Note right of Orch: APS price: 2.50
Prebid price: 2.00 - Med->>Med: Apply mediation policy and floors
Select highest CPM per slot + Med->>Med: Apply ad server policy and floors
Select highest CPM per slot Med-->>Orch: OpenRTB response with winners - Note right of Med: APS renderer state is restored from
the reduced source bid after mediation + Note right of Med: APS renderer state is restored from
the reduced source bid after the ad server answers deactivate Med end - else No Mediator (parallel_only) + else No Ad Server (parallel_only) rect rgb(253,243,235) Note over Client,Mock: Direct Winner Selection Orch->>Orch: Compare decoded prices
Apply slot floor
Select highest CPM @@ -184,12 +184,12 @@ POST /auction (AdRequest in Prebid.js format) ▼ AuctionOrchestrator.run_auction() │ - ├─ Detect strategy (parallel_only or parallel_mediation) + ├─ Detect strategy (parallel_only or parallel_adserver) ├─ Launch all providers in parallel via select() ├─ Collect responses as they complete │ ├─[parallel_only]─── Select highest decoded CPM per slot - └─[parallel_mediation]─── Forward decoded-price bids to mediator for final selection + └─[parallel_adserver]─── Forward decoded-price bids to the ad server for final selection │ ▼ Convert OrchestrationResult → OpenRTB 2.x Response @@ -207,9 +207,9 @@ 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/` | Provider-plan compilation and validation | -| `profile.rs` | `crates/trusted-server-core/src/auction/` | Typed OpenRTB profile policies | -| `routing.rs` | `crates/trusted-server-core/src/auction/` | Bidder ownership and provider routing | +| `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 | @@ -220,109 +220,115 @@ The orchestrator is composed of several modules: ### Configuration-first plan -At startup, Trusted Server compiles `[auction.providers]` and +At startup, Trusted Server compiles `[demand]`, `[adserver]` and `[auction.bidders]` through one registry into an immutable `AuctionPlan`. -Provider IDs, endpoints, profile defaults, routes, static extensions, and -notification policy are resolved once. The same `Arc` is shared by -the orchestrator and integration registry; request handling does not reinterpret -raw provider configuration. +Demand source names, endpoints, implementation defaults, routes, static +extensions and notification policy are resolved once. The same +`Arc` is shared by the orchestrator and the integration registry, +so request handling never reinterprets raw configuration. -The first version registers three OpenRTB 2.6 profiles in Rust: +Three demand implementations ship in this repository: -- `standard` for the common banner subset and bounded static extensions; -- `prebid-server` for PBS request, response, cache, override, and diagnostics +- `openrtb` for the common banner subset and bounded static extensions; +- `prebid_server` for PBS request, response, cache, override, and diagnostics behavior; and - `aps` for APS account/SDK fields, response eligibility, and renderer output. -Each configured provider is an instance of the generic planned OpenRTB path. -Multiple instances may select the same profile or endpoint and remain distinct -through their provider IDs. The existing `adserver_mock` mediator stays in a -separate static integration path selected by `[auction].mediator`. +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. +The `adserver_mock` ad server is selected the same way, by +`[adserver] provider`, and supplies an ad server implementation rather than a +demand one. See [Configuration Rules](/guide/configuration-rules) for the +syntax every provider type shares. ## Auction Strategies -The orchestrator automatically selects a strategy based on whether a `mediator` is configured. +The orchestrator automatically selects a strategy based on whether an ad server is configured. ### Parallel Only -When no mediator is set, the orchestrator runs all providers in parallel and selects winners by comparing decoded prices directly. This is the simplest strategy. +With no ad server, the orchestrator runs all demand sources in parallel and selects winners by comparing decoded prices directly. This is the simplest strategy. ```toml [auction] enabled = true timeout_ms = 2000 -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" routing = "explicit" -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" -profile_config = { account_id = "example-aps-account" } +account_id = "example-aps-account" [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" -# No mediator — direct price comparison +# No [adserver], so the highest bid wins ``` **How winner selection works:** -1. Collect bids from all providers. +1. Collect bids from all demand sources. 2. Group bids by slot ID. 3. Skip bids without a decoded numeric price. 4. Select the highest CPM for each slot. 5. Apply floor prices and drop winners below the slot's floor. -APS OpenRTB supplies decoded prices, so eligible APS bids participate directly without requiring a mediator. +APS OpenRTB supplies decoded prices, so eligible APS bids participate directly without needing an ad server. -### Parallel Mediation +### Parallel Ad Server -When a `mediator` is configured, provider responses are forwarded to the mediator service for final winner selection and unified floor pricing. +When an ad server is configured, demand responses are forwarded to it for final winner selection and unified floor pricing. ```toml [auction] enabled = true timeout_ms = 2000 -mediator = "adserver_mock" # Enables mediation -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" routing = "explicit" -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" -profile_config = { account_id = "example-aps-account" } +account_id = "example-aps-account" [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" -[integrations.adserver_mock] -enabled = true -endpoint = "https://mediator.example.com/mediate" +[adserver] +provider = "adserver_mock" + +[adserver.adserver_mock] +endpoint = "https://adserver.example.com/decide" timeout_ms = 500 ``` -**How mediation works:** +**How the ad server strategy works:** -1. Run all providers in parallel (same as parallel_only). +1. Run all demand sources in parallel (same as parallel_only). 2. Collect all responses. -3. Forward bids with decoded numeric prices to the mediator. -4. Let the mediator apply policy and choose a winner. +3. Forward bids with decoded numeric prices to the ad server. +4. Let the ad server apply policy and choose a winner. 5. Restore render/accounting state from the selected source bid. -6. Filter any mediator winner without a decoded price. +6. Filter any ad server winner without a decoded price. -Mediation is optional for APS. APS reduces to one candidate per impression before mediation so the selected renderer can be restored without same-slot ambiguity. +An ad server is optional for APS. APS reduces to one candidate per impression first, so the selected renderer can be restored without same-slot ambiguity. ## Providers @@ -369,17 +375,17 @@ Transforms auction requests into OpenRTB 2.x format and sends them to a Prebid S also included. ```toml -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" ``` ### APS Provider @@ -402,13 +408,13 @@ Builds an independent banner OpenRTB request for Amazon Publisher Services. - a minimized typed renderer is preserved instead of creative markup or APS notifications. ```toml -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand] +provider = ["aps_main"] + +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" - -[auction.providers.aps-main.profile_config] account_id = "example-aps-account" debug = false allow_script_creatives = false @@ -416,11 +422,11 @@ allow_script_creatives = false See [APS OpenRTB Integration](/guide/integrations/aps) for rollout and rendering requirements. -### AdServer Mock Mediator +### AdServer Mock -An external mediation service that receives decoded-price bidder responses and performs final winner selection. APS prices are already decoded at the provider boundary. +An external decision service that receives decoded-price demand responses and performs final winner selection. APS prices are already decoded at the demand boundary. -**Mediation request format:** +**Ad server request format:** ```json { @@ -446,12 +452,14 @@ An external mediation service that receives decoded-price bidder responses and p } ``` -**Mediation response:** Standard OpenRTB with decoded prices and selected winners. +**Ad server response:** Standard OpenRTB with decoded prices and selected winners. ```toml -[integrations.adserver_mock] -enabled = true -endpoint = "https://your-mediator.example.com/adserver/mediate" +[adserver] +provider = "adserver_mock" + +[adserver.adserver_mock] +endpoint = "https://adserver.example.com/decide" timeout_ms = 500 price_floor = 0.50 ``` @@ -509,7 +517,7 @@ pub struct Bid { } ``` -The `price` field remains optional so missing-price bids fail closed. APS supplies a decoded price and a typed renderer instead of creative HTML; the renderer is retained through direct winner selection and mediation. +The `price` field remains optional so missing-price bids fail closed. APS supplies a decoded price and a typed renderer instead of creative HTML, and the renderer is retained through direct winner selection and through the ad server. ### OrchestrationResult @@ -518,7 +526,7 @@ The complete result of an auction: ```rust pub struct OrchestrationResult { pub provider_responses: Vec, // All provider results - pub mediator_response: Option, // Mediator result (if used) + pub adserver_response: Option, // Ad server result (if used) pub winning_bids: HashMap, // Slot ID → winning bid pub total_time_ms: u64, pub metadata: HashMap, @@ -579,7 +587,7 @@ Auction results are returned in standard OpenRTB format with an `ext.orchestrato ], "ext": { "orchestrator": { - "strategy": "parallel_mediation", + "strategy": "parallel_adserver", "providers": 2, "total_bids": 3, "time_ms": 145 @@ -657,9 +665,9 @@ rewrite_creatives = true When sanitization is enabled, scripts, stylesheets, style blocks, forms, event handlers, dangerous URL schemes, and other rejected content are removed together with their inner content — which blanks script-based creatives. Disabling -rewriting removes the injected creative runtime and first-party proxy/click -mediation from the resulting `adm`, so the browser may contact third-party hosts -without mediation. Sanitizer-accepted hosts are not allowlisted or trusted +rewriting removes the injected creative runtime and the first-party proxy and +click rewriting from the resulting `adm`, so the browser may contact +third-party hosts directly. Sanitizer-accepted hosts are not allowlisted or trusted merely because their URLs remain in the output. Both settings apply to winning-bid `adm` in both the shared `POST /auction` @@ -700,43 +708,48 @@ Each proxied URL includes a `tstoken` HMAC signature for tamper protection. See ```toml [auction] enabled = true -sanitize_creatives = false # Opt-in; blanks script-based creatives when enabled +sanitize_creatives = false # Opt-in, blanks script-based creatives when enabled rewrite_creatives = true timeout_ms = 2000 -mediator = "adserver_mock" -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" timeout_ms = 900 routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false test_mode = false consent_forwarding = "both" -[auction.providers.pbs-main.notifications] +[demand.pbs_main.notifications] suppress_all = false suppress_seats = ["example-seat"] -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" - -[auction.providers.aps-main.profile_config] account_id = "example-aps-account" debug = false allow_script_creatives = false [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" -[integrations.prebid] -enabled = true +[adserver] +provider = "adserver_mock" + +[adserver.adserver_mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 + +[integration] +provider = ["prebid"] + +[integration.prebid] timeout_ms = 1000 debug = false client_side_bidders = ["example-browser"] @@ -744,53 +757,51 @@ external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" [proxy] allowed_domains = ["assets.example.com"] - -[integrations.adserver_mock] -enabled = true -endpoint = "https://mediator.example.com/mediate" -timeout_ms = 500 ``` -`[auction.providers]` is a map, not a provider-name list. Each provider ID owns -endpoint/backend correlation and telemetry. `[auction.bidders]` maps each -client-visible bidder ID to one provider. The mediator remains a separately -registered integration selected by `[auction].mediator`. +`[demand] provider` lists the demand sources and each `[demand.]` table +holds one source's settings. The name owns endpoint and backend correlation +and telemetry. `[auction.bidders]` maps each client-visible bidder ID to one +of those names, and a route naming a source the list does not select is +refused. The ad server is selected separately by `[adserver] provider`. -Common provider fields and defaults: +Four settings are common to every `[demand.]` table, whichever +implementation it names: -| Field | Default | Meaning | -| ---------------- | --------------- | -------------------------------------------------------------- | -| `protocol` | Required | `openrtb-2.6` | -| `profile` | `standard` | Typed OpenRTB behavior | -| `endpoint` | Required | Fixed absolute HTTPS endpoint | -| `timeout_ms` | Profile default | PBS 1000 ms, APS 800 ms, standard inherits auction timeout | -| `routing` | `explicit` | `explicit`, or `all_eligible` for non-PBS profiles | -| `profile_config` | `{}` | Profile-owned typed settings | -| `notifications` | No suppression | Common `nurl`/`burl` suppression by all bids or returned seats | +| Field | Default | Meaning | +| --------------- | ---------------------- | ---------------------------------------------------------------- | +| `endpoint` | Required | Absolute HTTPS endpoint, or HTTP to a loopback host | +| `timeout_ms` | Implementation default | PBS 1000 ms, APS 800 ms, `openrtb` inherits the auction timeout | +| `routing` | `explicit` | `explicit`, or `all_eligible` where the implementation allows it | +| `notifications` | No suppression | Common `nurl`/`burl` suppression by all bids or returned seats | + +Every other key in the table belongs to the implementation, which rejects any +key it does not know. APS normally uses `all_eligible`, which sends every compatible banner slot but -never another provider's bidder parameters. `explicit` providers receive only -centrally routed or trusted stored-request demand. The `prebid-server` profile -rejects `all_eligible` because PBS requires bidder or stored-request demand on -each impression. - -Provider IDs must match `^[a-z][a-z0-9-]{0,62}$`. Bidder IDs are limited to 128 -UTF-8 bytes and cannot be the exact reserved browser envelope ID -`trustedServer`. Static standard-profile `request_ext` and `imp_ext` objects -are each limited to 16 KiB, eight container levels, and 256 keys at one object -level. Notification seat lists are limited to 128 unique entries of at most 128 -UTF-8 bytes each. +never another source's bidder parameters. An `explicit` source receives only +centrally routed or trusted stored-request demand. The `prebid_server` +implementation rejects `all_eligible` because PBS requires bidder or +stored-request demand on each impression. + +Demand source names must match `^[a-z][a-z0-9_]{0,62}$`. Bidder IDs are limited +to 128 UTF-8 bytes and cannot be the exact reserved browser envelope ID +`trustedServer`. Static `openrtb` `request_ext` and `imp_ext` objects are each +limited to 16 KiB, eight container levels, and 256 keys at one object level. +Notification seat lists are limited to 128 unique entries of at most 128 UTF-8 +bytes each. ### Validation and target capability -Target-independent `ts config validate` compiles profiles, defaults, routes, -endpoints, bounds, signing structure, and mediator selection. Every adapter -startup compiles the same plan and then validates backend-name prediction, -fan-out capability, and target resource limits. Fastly and Axum allow -multi-provider fan-out; Cloudflare and Spin currently reject enabled auctions -with more than one provider. Fastly reserves 40 of its default 200 dynamic -backend names for non-auction traffic and rejects auction plans whose provider -IDs and reachable timeout buckets could require more than the remaining 160. +Target-independent `ts config validate` compiles implementations, defaults, +routes, endpoints, bounds, signing structure, and the ad server selection. +Every adapter startup compiles the same plan and then validates backend-name +prediction, fan-out capability, and target resource limits. Fastly and Axum +allow fan-out to several demand sources, whereas Cloudflare and Spin currently +reject an enabled auction with more than one. Fastly reserves 40 of its +default 200 dynamic backend names for non-auction traffic and rejects auction +plans whose demand source names and reachable timeout buckets could require +more than the remaining 160. This tree does not yet have the EdgeZero callback required to run target-aware validation before `ts config push --adapter ` performs remote work. @@ -798,20 +809,22 @@ Until that callback lands, startup remains the mandatory target-aware gate. ### Timeout behavior -For each provider, Trusted Server uses the smaller of its resolved timeout and -the remaining auction budget for launch decisions and OpenRTB `tmax`. The -mediator is not launched after the logical auction budget is exhausted. +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. -No current adapter claims an abortable provider-wide total-request deadline. +No current adapter claims an abortable total-request deadline across demand +sources. Already-launched work may complete after the logical budget, and a completed late response can remain eligible. Local decision and delivery also finish after network launch closes, so `timeout_ms` is not a hard wall-clock ceiling and an auction can exceed it. -Browser Prebid `timeout_ms` and `debug` stay under `[integrations.prebid]` and -are independent of all server provider values. Server endpoint, timeout, -routes, profile debug/test/overrides/consent, and notification suppression do -not belong to the browser integration. +Browser Prebid `timeout_ms` and `debug` stay under `[integration.prebid]` and +are independent of every demand source value. The server endpoint, timeout, +routes, debug, test mode, overrides, consent forwarding and notification +suppression belong to the `[demand.]` table, not to the browser +integration. ### Environment variable overrides @@ -819,20 +832,21 @@ The typed `ts config validate`, `ts config diff`, and `ts config push` flows can override existing scalar leaves. The pinned EdgeZero loader does not create missing leaves or replace arrays, tables, maps, or rules. Existing configs must add `rewrite_creatives = true` and `sanitize_creatives = false` before relying on -those scalar overrides. Edit and re-push TOML for other values. Provider map -keys preserve hyphens, so `pbs-main` uses the `PBS-MAIN` segment and needs -`env` shell syntax: +those scalar overrides. Edit and re-push TOML for other values. Every provider +name is snake_case, so a name maps straight onto a path segment: ```bash -env 'TRUSTED_SERVER__AUCTION__ENABLED=true' \ - 'TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES=true' \ - 'TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES=false' \ - 'TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000' \ - 'TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__PROFILE_CONFIG__DEBUG=true' \ - 'TRUSTED_SERVER__AUCTION__MEDIATOR=adserver_mock' \ - ts config validate +export TRUSTED_SERVER__AUCTION__ENABLED=true +export TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES=true +export TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES=false +export TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000 +export TRUSTED_SERVER__DEMAND__PBS_MAIN__DEBUG=true +ts config validate ``` +A `provider` list is an array, so `[demand] provider` and +`[adserver] provider` cannot be set this way. Edit the TOML and push it. + Before rolling back to a binary that does not know a creative-processing field, remove that field's non-default value (`rewrite_creatives = false` or `sanitize_creatives = true`), push the default-compatible blob, and then roll @@ -843,19 +857,19 @@ complete migration, upgrade-sequencing, and rollback guidance. Floor prices can be set per-slot in the auction request. The orchestrator enforces floors after winner selection: -- In **parallel_only** mode: bids below the floor are dropped after selection -- In **parallel_mediation** mode: the floor is sent to the mediator in `ext.config.price_floor`, and also enforced locally as a safety net +- In **parallel_only** mode, bids below the floor are dropped after selection +- In **parallel_adserver** mode, the floor is sent to the ad server in `ext.config.price_floor`, and also enforced locally as a safety net - Bids without a decoded numeric price are dropped before delivery in both strategies ## Error Handling The orchestrator is designed to be resilient: -- **Provider launch failure** — The provider records a `launch_failed` outcome and other providers continue. If every eligible provider fails before producing a pending or immediate outcome, direct `/auction` execution returns `502 Bad Gateway`. Split publisher execution records `dispatch_failed` telemetry and continues the origin response without bids. -- **Provider parse failure** — If a response can't be parsed, an `AuctionResponse::error()` is recorded. Other results are unaffected. -- **No providers configured** — Completes as a no-bid without provider I/O. -- **No provider produces a valid bid** — Returns an empty `OrchestrationResult` with zero winning bids after recording provider outcomes. -- **Mediator returns bids without decoded prices** — Those bids are filtered out with a warning. +- **Demand launch failure**. The demand source records a `launch_failed` outcome and the others continue. If every eligible provider fails before producing a pending or immediate outcome, direct `/auction` execution returns `502 Bad Gateway`. Split publisher execution records `dispatch_failed` telemetry and continues the origin response without bids. +- **Demand parse failure**. If a response cannot be parsed, an `AuctionResponse::error()` is recorded. Other results are unaffected. +- **No demand sources configured**. Completes as a no-bid without any network call. +- **No demand source produces a valid bid**. Returns an empty `OrchestrationResult` with zero winning bids after recording each outcome. +- **The ad server returns bids without decoded prices**. Those bids are filtered out with a warning. ## Observability @@ -866,8 +880,8 @@ The auction system logs at multiple levels throughout execution: | Level | Examples | | ------- | --------------------------------------------------------------------------------------- | | `info` | Auction request received, provider launch, bid counts, winner selection, total timing | -| `debug` | Bid-drop reasons, mediation restoration notes, creative processing mode and byte counts | -| `warn` | Provider launch failures, parse failures, mediator bids without decoded prices | +| `debug` | Bid-drop reasons, ad server restoration notes, creative processing mode and byte counts | +| `warn` | Demand launch failures, parse failures, ad server bids without decoded prices | ### Response Metadata @@ -875,7 +889,7 @@ Every auction response includes structured metadata in `ext.orchestrator`: ```json { - "strategy": "parallel_mediation", + "strategy": "parallel_adserver", "providers": 2, "total_bids": 3, "time_ms": 145 @@ -895,7 +909,7 @@ auction_html_comment = true [debug.auction_html_comment_options] include_provider_responses = true -include_mediator_response = false +include_adserver_response = false include_bids = false verbosity = "full" format = "pretty" @@ -910,13 +924,13 @@ fastly compute serve This example is useful when investigating raw Prebid Server requests and responses without spending the dump budget on winning creatives. Raw PBS `debug.httpcalls` and `resolvedrequest` metadata also require -`debug = true` under `[auction.providers..profile_config]` for the relevant -Prebid Server provider. +`debug = true` in the `[demand.]` table of the relevant Prebid Server +demand source. | Option | Default | Behavior | | ---------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------- | | `include_provider_responses` | `true` | Include the provider response array | -| `include_mediator_response` | `true` | Include the mediator response when a mediator ran | +| `include_adserver_response` | `true` | Include the ad server response when an ad server ran | | `include_bids` | `true` | Include bid objects; when `false`, provider status and metadata remain | | `metadata_keys` | `error_type`, `http_status`, `message` | Subset of the fixed validated keys; gates them in `redacted` and `upstream`, ignored in `full` | | `verbosity` | `redacted` | Select `redacted`, `upstream`, or `full` sensitivity | diff --git a/docs/guide/cli.md b/docs/guide/cli.md index ab013051b..67934e874 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -298,13 +298,14 @@ publisher-specific settings, then run: ts config validate ``` -The draft also fills `[integrations.js_asset_proxy]` with disabled third-party -script candidates from the audit. These entries are inventory only: they do not -register routes or rewrite HTML until you set -`integrations.js_asset_proxy.enabled = true` and change individual -`assets[].proxy` values to `"enabled"` or `"blocked"`. Some candidates may be -runtime-injected scripts; JS Asset Proxy only rewrites matching script `src` URLs -present in HTML processed by Trusted Server. +The draft also names in `[integration] provider` the integrations it can +configure from what it found, and writes their blocks. Where it found +third-party scripts it writes `[integration.js_asset_proxy]` with each one +`proxy = "disabled"`, so they are inventory only, and nothing is served or +rewritten until you review a candidate and change its `proxy` value to +`"enabled"` or `"blocked"`. Some candidates may be runtime-injected scripts, +and JS Asset Proxy only rewrites matching script `src` URLs present in HTML +processed by Trusted Server. If a config already exists, avoid overwriting it: @@ -665,7 +666,10 @@ APIs. `trusted-server.toml`. ```toml -[integrations.prebid.bundle.modules] +[integration] +provider = ["prebid"] + +[integration.prebid.bundle.modules] bidder = ["rubiconBidAdapter", "kargoBidAdapter"] user_id = ["sharedIdSystem"] analytics = ["atsAnalyticsAdapter"] @@ -687,7 +691,7 @@ ts prebid bundle By default, generated artifacts are written to `dist/prebid/`. The versioned manifest records effective module selections, bidder and analytics runtime codes, the content-addressed filename, SHA-256, and SRI. The command copies the -hash and SRI into `integrations.prebid` only after the generator and manifest +hash and SRI into `integration.prebid` only after the generator and manifest both pass validation. Upload the generated JavaScript file yourself, set `external_bundle_url` to its diff --git a/docs/guide/configuration-rules.md b/docs/guide/configuration-rules.md new file mode 100644 index 000000000..a85012016 --- /dev/null +++ b/docs/guide/configuration-rules.md @@ -0,0 +1,299 @@ +# Configuration Rules + +This page is for the people who write a Trusted Server configuration file and +for the people who write the code that reads it. It sets out the few rules +every part of the file follows, what Trusted Server checks before it serves a +request, and why the file is built this way. + +## One rule for every provider + +Almost everything Trusted Server can switch on is a provider: the Edge Cookie +identity provider, the location and device providers, the permission signals, +the demand sources in an auction, the ad server that picks the winner, and the +page integrations. Every one of them is configured the same way. + +```toml +[] +provider = "" # or a list, where several run + +[.] # only when the provider has settings +setting = "value" +``` + +1. **The type is the job.** Each type is one top-level table, named for what + its providers do: `ec`, `geo`, `device`, `permission_signal`, `demand`, + `adserver` and `integration`. +2. **`provider` chooses what runs.** A type that runs one provider takes a + string. A type that runs several takes a list. +3. **`[.]` holds the settings.** A provider with nothing to set + needs no table at all. +4. **The name is the implementation.** `[ec.hmac]` configures the `hmac` + implementation. To run an implementation under a name of your own, add an + `implementation` line. That is how two Prebid Servers run side by side. +5. **Names are snake_case,** like every key in the file. +6. **Secrets are key names.** A secret setting holds the name of a key in + `trusted_server_secrets`, never the secret itself. + +| Type | Runs | `provider` is | Implementations in this repository | +| ------------------- | ----------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ec` | one | a string | `hmac`, `host_signals`, `client_fixed` (demonstration builds), or an integration that supplies identity | +| `geo` | one | a string | `platform`, `none`, or an integration that supplies location | +| `device` | one | a string | `builtin` (the default), `fastly`, or an integration that supplies device signals | +| `permission_signal` | several, in order | a list | `gpc`, `gpp_sale_opt_out`, `us_privacy`, `tcf` | +| `demand` | several | a list | `openrtb`, `prebid_server`, `aps` | +| `adserver` | one | a string | `adserver_mock` | +| `integration` | several | a list | `datadome`, `didomi`, `google_tag_manager`, `gpt`, `gpt_diagnostics`, `js_asset_proxy`, `lockr`, `nextjs`, `osano`, `permutive`, `prebid`, `sourcepoint`, `testlight` | + +`openrtb`, `prebid_server`, `aps` and `adserver_mock` supply implementations +only. They are not page integrations and cannot be named in +`[integration] provider`. + +A provider that an integration supplies needs that integration named in +`[integration] provider` too, because the module has to be registered before +another type can select what it offers. + +A few types keep settings of their own beside `provider`, where the setting +belongs to the job rather than to one provider. `[ec]` holds `ec_store`, the +partner registry and the cluster thresholds, and `[geo]` holds +`assume_single_jurisdiction`. Those keys sit directly in the type's table. + +### Leaving `provider` out + +| Type | With no `provider` line | +| ------------------- | --------------------------------------------------------- | +| `ec` | no Edge Cookie is created | +| `geo` | no location is resolved and no host geo service is called | +| `device` | `builtin` runs, which reads the User-Agent only | +| `permission_signal` | every linked provider runs, in the order shown above | +| `demand` | no demand source is called | +| `adserver` | the highest bid wins, with no ad server | +| `integration` | no integration runs | + +## What is checked before a request is served + +Every rule below refuses startup, and the message names the table and the fix. +Some are caught earlier, when the configuration is validated or pushed, and +the two lists say which. + +- A `[.]` table that its type's `provider` does not name. A block + left behind after a provider is switched off is caught, rather than sitting + unused and misleading the next reader. +- A `provider` entry, or an `implementation` line, that names an + implementation this build does not have. The message lists the ones it does. +- A selected provider that needs a setting its table does not give, such as + `hmac` with no `passphrase`. +- A setting a provider does not know. Every provider rejects unknown keys, so + a misspelled setting fails instead of being ignored. +- A name that is not snake_case, or a name selected twice. +- A key in a type's table that is neither `provider`, one of that type's own + settings, nor a named provider table. +- A `demand` or `adserver` endpoint that is not HTTPS. Plain HTTP is allowed + only to `127.0.0.1`, `::1` or `localhost`, so a local test stack runs + without certificates and nothing leaves the machine unencrypted. An endpoint + carrying credentials or a fragment is refused in every case. +- A secret setting whose key name is missing from `trusted_server_secrets`. + +### Checked when the configuration is validated or pushed + +`ts config validate`, `ts config diff` and `ts config push` all run the same +set. It is everything that can be decided from the file and from the +implementations compiled into the CLI. + +- The whole auction plan, compiled from `[demand]`, `[adserver]` and + `[auction.bidders]`. That covers unselected tables, names that are not + snake_case, an implementation this build does not have, endpoint scheme and + host, timeouts, routing modes, notification bounds, a bidder route naming a + demand source `[demand] provider` does not select, and any setting the + chosen implementation rejects. +- Every selected integration's own settings, and the refusal of a block for an + integration `[integration] provider` does not name, of an `enabled` key left + behind in a block, and of the removed `[integrations]` table. +- Every secret setting holding a non-empty key name rather than a value, with a + secret store declared to hold it. +- Basic-auth coverage of the admin namespace, and the placeholder values the + template ships with. + +### Checked when the service starts + +Everything above runs again, on the configuration the instance actually +loaded, and these join it. + +- Which providers `[ec]`, `[geo]`, `[device]` and `[permission_signal]` + select. Those four are settled where the adapter composes the build, so a + name this build does not have, a missing settings table, or a table the + selector does not name, stops the service on its next start rather than the + push. **A passing `ts config validate` is not proof that a change to those + four will start.** Start an instance on the new configuration to find out. +- Assembling the integration registry, which is where a module that supplies an + identity, location or device provider is matched to the type that selected + it, and where an `[integration] provider` entry no builder in this build + supplies is refused. A module declaring an identity or device provider that + no type selects is logged as a warning here. +- The resolved secret values, which a key name alone cannot show. A weak or + placeholder password fails here. +- The compiled `permissions.yaml` policy, and the acknowledgment an Edge + Cookie provider needs when no geo provider is selected. +- The checks only the host can make, being backend name prediction and + collisions, and whether the adapter can call more than one demand source at + once. + +## Settings that are not providers + +The other tables configure Trusted Server itself rather than choose a provider. +They keep their own keys and have no `provider` line. + +| Table | Configures | +| ---------------------------------------------------------- | --------------------------------------------------------------------- | +| `[publisher]` | the site, its origin and the proxy secret | +| `[auction]` | whether auctions run, the whole-auction timeout and creative handling | +| `[auction.bidders.]` | which demand provider a browser bidder code is sent to | +| `[creative_opportunities]` | server-rendered ad slots | +| `[proxy]`, `[cache]`, `[rewrite]` | first-party proxying, caching and URL rewriting | +| `[request_signing]`, `[trusted_client_ip]`, `[[handlers]]` | signing, client addresses and admin access | +| `[debug]`, `[tinybird]` | diagnostics and telemetry | + +## Why the file works this way + +**For the people who run it.** There is one pattern to learn. Whether a +section chooses an identity provider or the demand sources for an auction, the +question "what runs, and how is it set up" is answered the same way, in the +same place. A change reads plainly in review, because switching a provider is +a change to one `provider` line. And mistakes are caught when the +configuration is validated or the server starts, not when a visitor's request +takes an unexpected path. A leftover table, a misspelled setting or a name the +build does not know all stop the deployment with a message that names the +fix. + +**For the people who write the code.** A provider plugs in through one +registration and inherits the checks above without writing them again. Core +code does not name any vendor, so adding a provider does not mean editing core, +and a vendor's crate can supply its own provider on the same terms as the ones +in this repository. + +**For everyone.** A configuration that cannot hold a silent mistake is one +that can be trusted in production and handed from one team to another. + +## A complete example + +A site with every kind of component the rules cover. + +```toml +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "handler_password" # key name in trusted_server_secrets + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +proxy_secret = "publisher_proxy_secret" # key name + +[proxy] +allowed_domains = ["assets.example.com"] + +[integration] +provider = ["prebid", "gpt"] + +[integration.prebid] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +timeout_ms = 1500 # the browser's Prebid timeout + +[integration.gpt] +gam_attribution_enabled = true + +[ec] +provider = "hmac" +ec_store = "ec_identity_store" + +[ec.hmac] +passphrase = "ec_passphrase" # key name + +[geo] +provider = "platform" + +[device] +provider = "builtin" + +[permission_signal] +provider = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"] + +[demand] +provider = ["pbs_main"] + +[demand.pbs_main] +implementation = "prebid_server" # the name is a label of your own +endpoint = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 1200 # this demand source only +consent_forwarding = "both" + +[adserver] +provider = "adserver_mock" + +[adserver.adserver_mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 + +[auction] +enabled = true +timeout_ms = 2000 # the whole auction + +[auction.bidders.example-server] +provider = "pbs_main" + +[creative_opportunities] +enabled = true +gam_network_id = "123456789" +auction_timeout_ms = 500 # the page's server-side auction + +[[creative_opportunities.slot]] +id = "leaderboard" +gam_unit_path = "/123456789/leaderboard" +div_id = "div-gpt-ad-leaderboard" +page_patterns = ["/"] +formats = [{ width = 728, height = 90 }] +``` + +Two Prebid Servers run side by side by giving each its own name and pointing +both at the same implementation. + +```toml +[demand] +provider = ["pbs_main", "pbs_house"] + +[demand.pbs_main] +implementation = "prebid_server" +endpoint = "https://prebid.example.com/openrtb2/auction" + +[demand.pbs_house] +implementation = "prebid_server" +endpoint = "https://house.example.com/openrtb2/auction" +``` + +## Moving from the previous layout + +| Previous | Now | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `[integrations.]` with `enabled = true` | `` in `[integration] provider`, and `[integration.]` only for settings | +| `[ec.providers.]` | `[ec.]` | +| `[permission_signal] sources` | `[permission_signal] provider` | +| `host-signals`, `client-fixed`, `gpp-sale-opt-out`, `us-privacy` | `host_signals`, `client_fixed`, `gpp_sale_opt_out`, `us_privacy` | +| `[auction.providers.]` with `protocol`, `profile` and `profile_config` | `[demand] provider` and `[demand.]`, with `implementation` and the settings flat in the table | +| `profile = "standard"` | `implementation = "openrtb"` | +| `[auction] mediator = "adserver_mock"` and `[integrations.adserver_mock]` | `[adserver] provider = "adserver_mock"` and `[adserver.adserver_mock]` | +| `[integrations.aps] rendering_mode` | `rendering_mode` in the `[demand.]` table of the `aps` provider | +| `[debug.auction_html_comment_options] include_mediator_response` | `include_adserver_response` | + +The word mediator is gone with it. It is "ad server" in prose and `adserver` +in configuration, and the auction response metadata that read +`parallel_mediation` now reads `parallel_adserver`. + +`[auction] providers` and `[auction] mediator` are not ignored. A +configuration still carrying either is refused with a message naming where the +setting moved to. + +## For developers adding a provider + +A provider is registered by its integration builder, and the configuration +rules above apply to it without any extra code. See the +[integration guide](/guide/integration-guide) for the registration itself. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 0d1612fd5..8125c3950 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -10,12 +10,25 @@ Trusted Server uses a flexible configuration system based on: 2. **Environment Variables** - Typed CLI overrides with the `TRUSTED_SERVER__` prefix 3. **EdgeZero Stores** - Config and secret stores for the pushed blob and runtime secret values +Everything the deployment can switch on is a provider, and every provider type +is written the same way. Read +[Configuration Rules](/guide/configuration-rules) first. It is short, and it +is the pattern every section below follows: + +```toml +[] +provider = "" # or a list, where several run + +[.] # only when the provider has settings +setting = "value" +``` + ## Quick Start ### Minimal Configuration Create `trusted-server.toml` in your project root. Generate both secret values -first with `openssl rand -base64 32`; the placeholders below are intentionally +first with `openssl rand -base64 32`. The placeholders below are intentionally rejected until replaced. ```toml @@ -26,6 +39,9 @@ origin_url = "https://origin.publisher.com" proxy_secret = "publisher_proxy_secret" [ec] +provider = "hmac" + +[ec.hmac] passphrase = "ec_passphrase" ``` @@ -39,9 +55,10 @@ read by the deployed application at request time. # Format: TRUSTED_SERVER__SECTION__FIELD export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -# Secret overrides, when needed, are key names—not secret values. +# Secret overrides, when needed, are key names, not secret values. export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret -export TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase +export TRUSTED_SERVER__EC__PROVIDER=hmac +export TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase # Replace the rejected placeholder values in trusted-server.toml, then validate. ts config validate @@ -55,13 +72,14 @@ publisher, trusted-client-IP, EC, handler, Tinybird, DataDome, and S3 fields: - `publisher.proxy_secret` - `trusted_client_ip.shared_secret`, when trusted client-IP forwarding is configured -- `ec.passphrase` +- `ec.hmac.passphrase`, when `[ec] provider = "hmac"` +- `ec.host_signals.passphrase`, when `[ec] provider = "host_signals"` - `ec.partners[*].api_token`, when inbound identify or batch sync is used - `ec.partners[*].ts_pull_token`, when pull sync is enabled - `handlers[*].password` - `tinybird.auction_token_secret`, when Tinybird auction telemetry is enabled -- `integrations.datadome.server_side_key_secret_name`, when protection is enabled -- `integrations.datadome.protection_test_bypass.credential_secret_name`, when the bypass is enabled +- `integration.datadome.server_side_key_secret_name`, when protection is enabled +- `integration.datadome.protection_test_bypass.credential_secret_name`, when the bypass is enabled - `proxy.asset_routes[*].auth.access_key_id`, `secret_access_key`, and optional `session_token` Their values belong in the logical `trusted_server_secrets` store and are @@ -132,8 +150,9 @@ Migrate an existing deployment in this order: 4. Restart/redeploy instances as needed to load the new values. Rotation is startup-scoped; changing a store value does not alter already-built state. -Keep `publisher.proxy_secret` and `ec.passphrase` stable unless intentionally -rotating signed URLs or EC identifiers. On Spin, the app-config blob is stored +Keep `publisher.proxy_secret` and the selected Edge Cookie provider's +passphrase stable unless intentionally rotating signed URLs or EC identifiers. +On Spin, the app-config blob is stored under the `trusted_server_config` key in Spin's built-in `default` key-value store. Set the corresponding CLI store mapping before pushing so the write matches the runtime lookup: @@ -187,34 +206,44 @@ fail and the service will return its startup-error response. | File | Purpose | | --------------------- | ------------------------------- | | `trusted-server.toml` | Main application configuration | +| `permissions.yaml` | Country/region permission rules | | `fastly.toml` | Fastly Compute service settings | | `.env.dev` | Local development overrides | ## Key Sections -| Section | Purpose | -| -------------------------- | ----------------------------------------------------------------------- | -| `[auction]` | Auction orchestration, provider instances, bidder routes, and mediation | -| `[cache]` | Static and rehosted asset cache policy | -| `[consent]` | Consent interpretation, forwarding, and conflict resolution | -| `[creative_opportunities]` | Server-side page ad opportunities and templates | -| `[debug]` | Explicit non-production diagnostics | -| `[ec]` | Edge Cookie identity, persistence, and partner sync | -| `[[handlers]]` | Ordered HTTP Basic-auth rules | -| `[image_optimizer]` | Reusable Fastly Image Optimizer profiles | -| `[integrations.*]` | Typed partner and browser integration settings | -| `[proxy]` | Proxy allowlist, TLS policy, and asset routes | -| `[publisher]` | Publisher domain, origin, and proxy signing key | -| `[request_signing]` | Outbound Ed25519 request signing and management-store IDs | -| `[response_headers]` | Headers added to Trusted Server responses | -| `[rewrite]` | First-party URL rewrite exclusions | -| `[tester_cookie]` | Optional tester-cookie endpoints | -| `[tinybird]` | Direct Tinybird auction telemetry | -| `[trusted_client_ip]` | Authenticated front-door client-IP forwarding | +7 of these sections are provider types. Each takes a `provider` key +and gives each selected provider its own `[.]` settings table, as +[Configuration Rules](/guide/configuration-rules) describes. + +| Section | Provider type | Purpose | +| -------------------------- | ------------- | ----------------------------------------------------------- | +| `[adserver]` | yes, one | The ad server that picks the winner | +| `[auction]` | no | Auction orchestration, bidder routes, and mediation | +| `[cache]` | no | Static and rehosted asset cache policy | +| `[consent]` | no | Consent interpretation, forwarding, and conflict resolution | +| `[creative_opportunities]` | no | Server-side page ad opportunities and templates | +| `[debug]` | no | Explicit non-production diagnostics | +| `[demand]` | yes, several | The auction's demand sources | +| `[device]` | yes, one | Device classification | +| `[ec]` | yes, one | Edge Cookie identity, persistence, and partner sync | +| `[geo]` | yes, one | Which provider resolves location, if any | +| `[[handlers]]` | no | Ordered HTTP Basic-auth rules | +| `[image_optimizer]` | no | Reusable Fastly Image Optimizer profiles | +| `[integration]` | yes, several | Partner and browser integrations | +| `[permission_signal]` | yes, several | Which permission signals are acted on, in order | +| `[proxy]` | no | Proxy allowlist, TLS policy, and asset routes | +| `[publisher]` | no | Publisher domain, origin, and proxy signing key | +| `[request_signing]` | no | Outbound Ed25519 request signing and management-store IDs | +| `[response_headers]` | no | Headers added to Trusted Server responses | +| `[rewrite]` | no | First-party URL rewrite exclusions | +| `[tester_cookie]` | no | Optional tester-cookie endpoints | +| `[tinybird]` | no | Direct Tinybird auction telemetry | +| `[trusted_client_ip]` | no | Authenticated front-door client-IP forwarding | ## Example: Production Setup -Generate and substitute every `replace-with-*` value before validation or +Generate and substitute every placeholder value before validation or deployment. ```toml @@ -225,6 +254,9 @@ origin_url = "https://origin.publisher.com" proxy_secret = "publisher_proxy_secret" [ec] +provider = "hmac" + +[ec.hmac] passphrase = "ec_passphrase" [request_signing] @@ -232,8 +264,10 @@ enabled = true config_store_id = "01GXXX" secret_store_id = "01GYYY" -[integrations.prebid] -enabled = true +[integration] +provider = ["prebid"] + +[integration.prebid] client_side_bidders = ["example-browser-bidder"] external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" @@ -244,18 +278,18 @@ allowed_domains = ["assets.example.com"] enabled = true timeout_ms = 2000 -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" timeout_ms = 1200 routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false [auction.bidders.example-server-bidder] -provider = "pbs-main" +provider = "pbs_main" ``` ## Detailed Reference @@ -288,20 +322,20 @@ TRUSTED_SERVER__SECTION__SUBSECTION__FIELD - Separator: `__` (double underscore) - Case: UPPERCASE - Sections: Match TOML hierarchy -- Map keys: Preserve TOML punctuation. For example, provider key `pbs-main` - uses the `PBS-MAIN` segment, not `PBS_MAIN`. +- Provider names are snake_case, so a provider table maps + straight onto a path segment. `[demand.pbs_main] debug` is + `TRUSTED_SERVER__DEMAND__PBS_MAIN__DEBUG`. -Shell assignment syntax cannot contain a hyphenated variable name. Use `env` -to apply a provider override to a command: +A provider setting overrides like any other scalar leaf: ```bash -env 'TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__PROFILE_CONFIG__DEBUG=true' \ - ts config validate +export TRUSTED_SERVER__DEMAND__PBS_MAIN__DEBUG=true +ts config validate ``` This example changes an existing scalar leaf. Edit TOML and run `ts config validate` followed by `ts config push` when changing an array, table, map, or -rule. +rule. A `provider` list is an array, so it cannot be overridden this way. ## Publisher Configuration @@ -440,7 +474,7 @@ Changing `proxy_secret` invalidates all existing signed URLs. Plan rotations car #### `max_buffered_body_bytes` **Purpose**: Upper bound on how much of a publisher origin body the rewrite -pipeline holds in memory — the post-rewrite output buffer on buffered adapters, +pipeline holds in memory, being the post-rewrite output buffer on buffered adapters, and the per-stream raw/decoded byte ceiling on the Fastly streaming path. **Usage**: @@ -464,7 +498,7 @@ and the per-stream raw/decoded byte ceiling on the Fastly streaming path. - On **buffered adapters** the response fails before any bytes are committed. - On the **streaming path** the response headers are already committed when either cap trips, so the body is **truncated mid-stream** and the error is - logged — the client receives a short (incomplete) body rather than a `5xx`. + logged, and the client receives a short (incomplete) body rather than a `5xx`. Size the cap above your largest expected decoded page so legitimate responses are never truncated. @@ -629,17 +663,49 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ### `[ec]` -`passphrase` is a key name in `trusted_server_secrets`; the resolved value must -be at least 32 bytes. Keep it stable to preserve EC identifier continuity. +| Field | Type | Required | Description | +| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Name of the active Edge Cookie provider: `"hmac"` (built-in), `"host_signals"` (opt-in), `"none"` (explicitly stateless), or a provider an integration supplies. Omit to run statelessly with no Edge Cookie. The `"client_fixed"` demonstration provider needs the `client-fixed-demo` build feature | +| `resolve_allowed_origins` | Array | No | Extra exact origins allowed to POST the client resolve endpoint, beyond `https://{publisher.domain}` | +| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | +| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | +| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | +| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting, because cluster rechecks no longer use timestamps | +| `partners` | Array | No | Static partner registry entries | + +Each provider that has settings is configured in its own `[ec.]` table, and the `provider` selector names which table is active. A table may set `implementation = ""` to say which provider it configures, which makes the table name a label of your choosing, so `provider = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in provider under a name that means something to your deployment. Provider names and implementation ids are `snake_case`. + +A provider has a table only when it has settings of its own. Both providers that derive an identifier at the edge take a passphrase, so selecting `hmac` or `host_signals` without its table fails at startup, while the `client_fixed` demonstration provider needs no table at all. A table the selector does not name also fails at startup, so a stale table cannot sit unnoticed. + +`ec_store`, `partners` and the cluster thresholds are settings of the job +rather than of one provider, so they sit directly in `[ec]` whichever provider +is selected. + +A provider an integration supplies also needs that integration named in +`[integration] provider`. -| Field | Type | Required | Description | -| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------- | -| `passphrase` | String | Yes | Publisher passphrase used as HMAC key | -| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | -| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | -| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | -| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | -| `partners` | Array | No | Static partner registry entries | +### `[ec.hmac]` + +A provider an integration supplies also needs that integration named in +`[integration] provider`. + +### `[ec.hmac]` + +The built-in HMAC-over-client-IP provider, named `hmac`. + +`passphrase` is a key name in `trusted_server_secrets`, and the resolved value +must be at least 32 bytes. Keep it stable to preserve EC identifier continuity. + +| Field | Type | Required | Description | +| ------------ | ------ | --------------------------- | ---------------------------------------------------------- | +| `passphrase` | String | Yes when `hmac` is selected | Secret-store key name whose resolved value is the HMAC key | + +### `[ec.host_signals]` + +The built-in provider that derives the identifier from the host's TLS JA4 and +HTTP/2 signals together with the client address, so it needs a host that +supplies those signals. It takes a `passphrase` on the same terms as +`[ec.hmac]`. ::: tip Partner keying `source_domain` is the canonical partner key. It matches incoming OpenRTB EID `source` values and is also used as the EC KV `ids` map key. @@ -654,9 +720,12 @@ outbound pull sync, but cannot authenticate to those inbound APIs. ```toml [ec] -passphrase = "ec_passphrase" +provider = "hmac" ec_store = "ec_identity_store" +[ec.hmac] +passphrase = "ec_passphrase" + [[ec.partners]] name = "Mocktioneer SSP" source_domain = "mocktioneer.example" @@ -668,25 +737,132 @@ bidstream_enabled = true **Environment Override**: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase +TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase TRUSTED_SERVER__EC__EC_STORE=ec_identity_store ``` +These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environment values into the published configuration (for example test harnesses building an app-config blob). The running server reads its settings from the platform config store, so provider selection changes take effect when a new configuration is pushed, not per request. + ### Field Details -#### `passphrase` +#### `provider` + +**Purpose**: Names the active Edge Cookie provider. Omit to run statelessly with no Edge Cookie. -**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation. +**Validation**: Application startup fails if the name is not `snake_case`, if it names a key the `[ec]` section reads as its own setting, if the selected provider has no `[ec.]` table where it needs one, if it names a provider this build does not have, or if a table the selector does not name is configured. `ts config validate` does not run these checks, so start an instance to confirm a change to `[ec]`. + +#### `hmac.passphrase` + +**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation, read when `provider = "hmac"`. **Security**: -- The key name is stored in app config; the value is stored in `trusted_server_secrets` +- The key name is stored in app config, and the value is stored in `trusted_server_secrets` - Keep the value stable unless intentionally rotating EC identifiers - Do not place the value in environment overlays or the pushed blob -**Validation**: Application startup fails if: +**Validation**: Application startup fails if the resolved value is: + +- Empty +- Shorter than 32 characters + +## Device Configuration + +Selects how a request is classified into the coarse device signals the Edge Cookie bot gate uses, mirroring the Edge Cookie provider selection. These signals serve identifier gating and bot detection, not bid enrichment. + +### `[device]` + +| Field | Type | Required | Description | +| ---------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `provider` | String or null | No | Name of the device-detection provider: `builtin` (the default, User-Agent only, no host-specific call), `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers, or a provider an integration supplies | + +The default `builtin` provider classifies from the User-Agent alone and makes no host-specific call, so the default path stays host-neutral. Neither `builtin` nor `fastly` has settings, so neither needs a `[device.]` table. Selecting a provider this build does not have fails at startup. + +**Example**: + +```toml +[device] +provider = "builtin" # or "fastly" to add TLS and HTTP/2 evidence +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__DEVICE__PROVIDER=builtin +``` + +## Geo Configuration + +Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie provider selection. The resolved country also feeds the [permission model](/guide/permission-model). + +### `[geo]` + +| Field | Type | Required | Description | +| ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Name of the geo provider: `platform` to use the host's own geo lookup, `none` (or omit it) to resolve no location and make no host geo call, or a provider an integration supplies | +| `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie provider without a geo provider acknowledges that by setting this to `true`. | + +`assume_single_jurisdiction` is a setting of the job rather than of one +provider, so it sits directly in `[geo]`. + +No provider is the default, so a default deployment is not tied to any host geo service. Selecting a provider this build does not have fails at startup. A failed geo lookup at request time resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. + +**Example**: + +```toml +[geo] +provider = "platform" +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__GEO__PROVIDER=platform +``` + +## Permission Signal Configuration + +Which permission signals Trusted Server acts on, and in what order. Signals +compose rather than select, because a request can carry a TCF string and a +Global Privacy Control header at once and both have something to say, so this +type takes a list. The order is the policy, because the last provider with an +opinion decides. -- Empty string +### `[permission_signal]` + +| Field | Type | Required | Description | +| ---------- | ------------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| `provider` | Array[String] | No | The providers to act on, in order. Omit it to act on every provider this build links, in the order shown below | + +The providers that ship are `gpc` (the `Sec-GPC` request header), +`gpp_sale_opt_out` (a GPP US sale opt-out), `us_privacy` (a US Privacy string +sale opt-out) and `tcf` (TCF v2). Each is a crate under +`crates/permission-signal`, outside the core. + +A provider that is not on the list does not run, and there is no separate +switch to turn one off. An empty list acts on nothing, leaving every +permission at its country and region baseline. An unknown or repeated name +refuses startup. None of the four has settings, so none needs a +`[permission_signal.]` table. + +**Example**: + +```toml +[permission_signal] +provider = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"] +``` + +See [Permission Signals](/guide/permission-signals) for what each provider +reads and how to add a scheme. + +## Provider Permissions + +A provider advertises the technical permissions its data use requires, and Trusted Server runs the provider only when every required permission is set. This separates legal policy from the core, so the deployer brings the policy that decides how permissions are established. See the [Permission Model](/guide/permission-model) for the concept, the permission vocabulary, and how a request resolves. + +### Country and region rules (`permissions.yaml`) + +The country and region permission rules are defined in a human-editable permissions YAML document, compiled into the build (not loaded at runtime). The repository sample is `config/permissions/sample.yaml`, which is for testing and evaluation only and is neither a production policy nor legal advice. Edit or replace the compiled-in file and rebuild to change the policy. There is no `[permissions]` block in `trusted-server.toml`. It defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule resolves at the top of the rules tree. See the [Permission Model](/guide/permission-model) for the schema and the repository sample. ## Consent Configuration @@ -916,7 +1092,7 @@ Startup fails when no handler covers an admin route. The dynamic `/_ts/admin/ec/{id}` route accepts any segment after `/_ts/admin/ec/`, and Basic Auth runs on the raw path before routing, so coverage cannot be inferred from ID-shaped samples: a pattern such as -`^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level +`^/_ts/admin/ec/hmac~[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level matcher (`^/_ts/admin`, or `^/_ts/admin/ec/` alongside the other admin patterns). @@ -925,7 +1101,7 @@ percent-encoded aliases before routing. For a whole-site staging gate, use `path = "^/"`; do not rely on a decoded-path prefix such as `^/secure` to protect equivalent origin paths. -Startup also fails when any handler — admin or not — uses a placeholder or +Startup also fails when any handler, admin or not, uses a placeholder or well-known weak password (`changeme`, `password`, `admin`, or a `replace-with-…` template value). Handler selection is first-match-wins, so a narrow handler ahead of the admin pattern governs the paths it matches. @@ -945,7 +1121,7 @@ browser-facing endpoints that anonymous visitors must be able to reach: | `/_ts/api/v1/batch-sync` | Trusted Server JS, in the browser | A pattern such as `path = "^/_ts"` puts those behind Basic Auth. Browser -fetches never carry Basic credentials, so every visitor gets `401` — on +fetches never carry Basic credentials, so every visitor gets `401`, on `/_ts/page-bids` that means no ads after any client-side navigation. Match the admin routes specifically (`^/_ts/admin`) instead. @@ -1090,7 +1266,7 @@ EdgeZero v0.0.4 cannot replace this array or address its elements by index. Edit #### `allowed_domains` -**Purpose**: Allowlist of target hosts permitted for `/first-party/sign` and `/first-party/proxy`. When `integrations.prebid.external_bundle_url` is configured, this list must cover its host and any HTTPS redirect targets. +**Purpose**: Allowlist of target hosts permitted for `/first-party/sign` and `/first-party/proxy`. When `integration.prebid.external_bundle_url` is configured, this list must cover its host and any HTTPS redirect targets. **Behavior**: Trusted Server checks the parsed host before signing a target, before fetching the initial proxy target, and before following each HTTP redirect (301/302/303/307/308). A host that does not match the list is blocked with a 403 error. @@ -1103,8 +1279,8 @@ EdgeZero v0.0.4 cannot replace this array or address its elements by index. Edit | `assets.example.com` | `assets.example.com` | `sub.assets.example.com` | | `*.cdn.example.com` | `cdn.example.com`, `static.cdn.example.com`, `a.b.cdn.example.com` | `evil-cdn.example.com` | -- `"example.com"` — exact match only. -- `"*.example.com"` — matches the base domain and any subdomain at any depth. +- `"example.com"` matches that host exactly and nothing else. +- `"*.example.com"` matches the base domain and any subdomain at any depth. - Matching is case-insensitive; entries are normalized to lowercase at startup. - Blank entries are ignored. - The `*` wildcard requires a dot boundary: `*.example.com` does **not** match `evil-example.com`. @@ -1440,40 +1616,55 @@ tracked in [#908](https://github.com/IABTechLab/trusted-server/issues/908). ## Integration Configurations -Every deploy-validated integration ID is listed here. A section is optional -unless its integration is enabled or a CLI workflow retains an explicit -disabled stub. - -| Section | Reference | -| ----------------------------------- | ------------------------------------------------------------------ | -| `[integrations.adserver_mock]` | [Ad Server Mock](/guide/integrations/adserver_mock) | -| `[integrations.aps]` | [APS](/guide/integrations/aps) | -| `[integrations.datadome]` | [DataDome](/guide/integrations/datadome) | -| `[integrations.didomi]` | [Didomi](/guide/integrations/didomi) | -| `[integrations.google_tag_manager]` | [Google Tag Manager](/guide/integrations/google_tag_manager) | -| `[integrations.gpt]` | [GPT](/guide/integrations/gpt) | -| `[integrations.gpt_diagnostics]` | [GPT diagnostics](/guide/integrations/gpt-diagnostics) | -| `[integrations.js_asset_proxy]` | [JS Asset Proxy](#js-asset-proxy-integration) (no dedicated guide) | -| `[integrations.lockr]` | [lockr](/guide/integrations/lockr) | -| `[integrations.nextjs]` | [Next.js](/guide/integrations/nextjs) | -| `[integrations.osano]` | [Osano](/guide/integrations/osano) | -| `[integrations.permutive]` | [Permutive](/guide/integrations/permutive) | -| `[integrations.prebid]` | [Prebid](/guide/integrations/prebid) | -| `[integrations.sourcepoint]` | [Sourcepoint](/guide/integrations/sourcepoint) | -| `[integrations.testlight]` | [Testlight](/guide/integrations/testlight) | - -### Common Fields - -All integrations support an `enabled` flag. Defaults vary by integration and only -apply when the integration section exists in `trusted-server.toml`. - -| Field | Type | Description | -| --------- | ------- | ------------------------------ | -| `enabled` | Boolean | Enable/disable the integration | +`[integration] provider` lists the integrations that run, and each one that has +settings gets its own `[integration.]` table. There is no `enabled` flag, +because an integration that is not on the list does not run. The full rule set +is in [Configuration Rules](/guide/configuration-rules). Every integration that +deploy validation knows is listed below. + +| Section | Reference | +| ---------------------------------- | ------------------------------------------------------------------ | +| `[integration.adserver_mock]` | [Ad Server Mock](/guide/integrations/adserver_mock) | +| `[integration.aps]` | [APS](/guide/integrations/aps) | +| `[integration.datadome]` | [DataDome](/guide/integrations/datadome) | +| `[integration.didomi]` | [Didomi](/guide/integrations/didomi) | +| `[integration.google_tag_manager]` | [Google Tag Manager](/guide/integrations/google_tag_manager) | +| `[integration.gpt]` | [GPT](/guide/integrations/gpt) | +| `[integration.gpt_diagnostics]` | [GPT diagnostics](/guide/integrations/gpt-diagnostics) | +| `[integration.js_asset_proxy]` | [JS Asset Proxy](#js-asset-proxy-integration) (no dedicated guide) | +| `[integration.lockr]` | [lockr](/guide/integrations/lockr) | +| `[integration.nextjs]` | [Next.js](/guide/integrations/nextjs) | +| `[integration.osano]` | [Osano](/guide/integrations/osano) | +| `[integration.permutive]` | [Permutive](/guide/integrations/permutive) | +| `[integration.prebid]` | [Prebid](/guide/integrations/prebid) | +| `[integration.sourcepoint]` | [Sourcepoint](/guide/integrations/sourcepoint) | +| `[integration.testlight]` | [Testlight](/guide/integrations/testlight) | + +### Naming the integrations that run + +```toml +[integration] +provider = ["prebid", "gpt", "nextjs"] +``` + +The integrations this repository ships are `datadome`, `didomi`, +`google_tag_manager`, `gpt`, `gpt_diagnostics`, `js_asset_proxy`, `lockr`, +`nextjs`, `osano`, `permutive`, `prebid`, `sourcepoint` and `testlight`. A +name this build does not have refuses startup, and the message lists the ones +it does. A `[integration.]` table for an integration the list does not +name refuses startup too, so a block left behind after an integration is +switched off is caught rather than sitting unread. + +`openrtb`, `prebid_server`, `aps` and `adserver_mock` supply demand and ad +server implementations only. They are not page integrations and cannot be +named here. Their settings live in `[demand.]` and `[adserver.]`. + +The sections below cover Prebid, Next.js, Osano, Permutive and Testlight. For +the others, see the relevant integration guides. ### Ad Server Mock Integration -**Section**: `[integrations.adserver_mock]` +**Section**: `[integration.adserver_mock]` This integration is the optional auction mediator selected by `auction.mediator = "adserver_mock"`; it is not an OpenRTB provider. @@ -1488,7 +1679,7 @@ This integration is the optional auction mediator selected by ### APS Browser Integration -**Section**: `[integrations.aps]` +**Section**: `[integration.aps]` | Field | Type | Default | Contract | | ---------------- | ------- | ------------------ | -------------------------------------- | @@ -1501,7 +1692,7 @@ Server endpoint, timeout, account, inventory, and debug settings belong to an ### DataDome Integration -**Section**: `[integrations.datadome]` +**Section**: `[integration.datadome]` The [DataDome guide](/guide/integrations/datadome) explains request behavior and exclusion-rule syntax. This table covers every canonical top-level field: @@ -1548,7 +1739,7 @@ to new configurations. ### Didomi Integration -**Section**: `[integrations.didomi]` +**Section**: `[integration.didomi]` | Field | Type | Default | Contract | | ------------ | -------------- | -------------------------------- | ------------------------------------------------------------------- | @@ -1561,7 +1752,7 @@ See [Didomi](/guide/integrations/didomi) for the routed endpoint shapes. ### Google Tag Manager Integration -**Section**: `[integrations.google_tag_manager]` +**Section**: `[integration.google_tag_manager]` | Field | Type | Default | Contract | | ---------------------- | ------- | ---------------------------------- | --------------------------------------------------- | @@ -1575,7 +1766,7 @@ See [Google Tag Manager](/guide/integrations/google_tag_manager). ### GPT Integration -**Section**: `[integrations.gpt]` +**Section**: `[integration.gpt]` | Field | Type | Default | Contract | | ------------------------- | -------------- | ----------------------- | ------------------------------------------------------ | @@ -1590,7 +1781,7 @@ See [GPT](/guide/integrations/gpt). ### GPT Diagnostics Integration -**Section**: `[integrations.gpt_diagnostics]` +**Section**: `[integration.gpt_diagnostics]` The only field is `enabled`, a Boolean that defaults to `false`. When enabled, the standalone diagnostics tag is available, but individual browser sessions @@ -1598,7 +1789,7 @@ still require the activation flow in [GPT diagnostics](/guide/integrations/gpt-d ### JS Asset Proxy Integration -**Section**: `[integrations.js_asset_proxy]` +**Section**: `[integration.js_asset_proxy]` Serves explicitly configured third-party JavaScript assets from first-party paths. Each asset maps one exact publisher-facing path to one exact HTTPS @@ -1613,7 +1804,7 @@ guide; the registered routes appear in the | `cache_ttl_seconds` | Integer | None | Optional downstream cache TTL for every asset | | `assets` | Array | `[]` | Asset mappings; required when enabled | -Each `[[integrations.js_asset_proxy.assets]]` entry: +Each `[[integration.js_asset_proxy.assets]]` entry: | Field | Type | Default | Contract | | ------------------- | ------- | --------- | --------------------------------------------------------- | @@ -1624,7 +1815,7 @@ Each `[[integrations.js_asset_proxy.assets]]` entry: ### lockr Integration -**Section**: `[integrations.lockr]` +**Section**: `[integration.lockr]` | Field | Type | Default | Contract | | ------------------- | --------------- | --------------------------------------------------- | ------------------------------------ | @@ -1641,13 +1832,13 @@ See [lockr](/guide/integrations/lockr). ### Prebid Integration -`[integrations.prebid]` owns browser behavior only. Server endpoint, provider -timeout, routing, profile debug/test controls, consent forwarding, bidder-param -overrides, and notification suppression belong under `[auction]`. +`[integration.prebid]` owns browser behavior only. The server endpoint, the +demand source timeout, routing, debug and test controls, consent forwarding, +bidder-param overrides, and notification suppression belong to a `[demand]` +source with `implementation = "prebid_server"`. | Browser field | Type | Default | Description | | ------------------------------------ | ------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `enabled` | Boolean | `true` | Enable browser bundle injection, interception, and the `trustedServer` adapter | | `account_id` | String | `None` | Optional account value injected into browser Prebid configuration | | `timeout_ms` | Integer | `1000` | Browser Prebid.js timeout; independent of every server provider timeout | | `debug` | Boolean | `false` | Browser Prebid.js debug flag; independent of server profile debug | @@ -1663,24 +1854,26 @@ overrides, and notification suppression belong under `[auction]`. Server-side bidder codes are derived from validated `[auction.bidders.*]` routes and injected into the browser. There is no second server bidder list in -`[integrations.prebid]`. A browser bidder stays client-side only when named in +`[integration.prebid]`. A browser bidder stays client-side only when named in `client_side_bidders` and its adapter is present in the generated bundle. **Example**: ```toml -[integrations.prebid] -enabled = true +[integration] +provider = ["prebid"] + +[integration.prebid] timeout_ms = 1000 debug = false client_side_bidders = ["rubicon"] external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" script_patterns = ["/prebid.js", "/prebid.min.js"] -[[integrations.prebid.managed_user_ids]] +[[integration.prebid.managed_user_ids]] name = "sharedId" -[integrations.prebid.managed_user_ids.storage] +[integration.prebid.managed_user_ids.storage] type = "cookie" name = "_sharedid" expires = 15 @@ -1689,52 +1882,50 @@ refresh_in_seconds = 1800 [proxy] allowed_domains = ["assets.example.com"] -[integrations.prebid.bundle.modules] +[integration.prebid.bundle.modules] bidder = ["rubiconBidAdapter"] user_id = ["sharedIdSystem"] analytics = ["atsAnalyticsAdapter"] +[demand] +provider = ["pbs_main"] -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false test_mode = false consent_forwarding = "both" bid_param_overrides = { example-server = { placement = "example-placement" } } -[[auction.providers.pbs-main.profile_config.bid_param_override_rules]] +[[demand.pbs_main.bid_param_override_rules]] when.bidder = "example-server" when.zone = "header" set = { placement = "example-header-placement" } -[auction.providers.pbs-main.notifications] +[demand.pbs_main.notifications] suppress_all = false suppress_seats = ["example-seat"] [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" ``` **Environment override**: ```bash -env 'TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true' \ - 'TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000' \ - 'TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__PROFILE_CONFIG__DEBUG=true' \ +env 'TRUSTED_SERVER__INTEGRATION__PREBID__TIMEOUT_MS=1000' \ + 'TRUSTED_SERVER__DEMAND__PBS_MAIN__DEBUG=true' \ ts config validate ``` -Environment overlays only replace existing scalar leaves. Keep -`client_side_bidders`, provider profile tables, bidder-parameter overrides, and -rules in TOML, then validate and push the edited file. +Environment overlays only replace existing scalar leaves. Keep the `provider` +lists, `client_side_bidders`, bidder-parameter overrides and rules in TOML, +then validate and push the edited file. **Managed User ID modules**: -Each `[[integrations.prebid.managed_user_ids]]` entry names a Prebid +Each `[[integration.prebid.managed_user_ids]]` entry names a Prebid `userSync.userIds` module that Trusted Server installs on the page and reinstates whenever publisher JavaScript replaces the User ID configuration. Trusted Server does not interpret module-specific fields; every registered @@ -1751,7 +1942,7 @@ module uses the same vendor-neutral surface. The managed `name` must match a | `storage.refresh_in_seconds` | Integer | Prebid's own default | Seconds before the module may refresh the stored value; must be at least 1 | The module must be present in the built bundle. Name it under -`[integrations.prebid.bundle].user_id_modules`, or omit that list to take the +`[integration.prebid.bundle.modules].user_id`, or omit that list to take the generator's default preset. `ts prebid bundle` resolves each managed `name` through the checked-in `user_id_modules.json` registry, rejects unknown names, ambiguous names, and two names that resolve to the same module, and confirms the @@ -1784,33 +1975,34 @@ See [Prebid Integration](/guide/integrations/prebid) for full details. **Server Bid Param Override Surfaces**: -These fields belong under -`[auction.providers..profile_config]` for a `prebid-server` provider: +These fields belong in the `[demand.]` table of a `prebid_server` +demand source: - `bid_param_overrides`: static per-bidder shallow-merge overrides; - `bid_param_zone_overrides`: per-bidder, per-zone shallow-merge overrides; and - `bid_param_override_rules`: canonical ordered rules with `when` matchers and `set` objects. -Compatibility-shaped fields are normalized into the same profile-local runtime -engine. Explicit rules run after compatibility-derived rules, so later rules -win on conflicts. +Compatibility-shaped fields are normalized into the same runtime engine. +Explicit rules run after compatibility-derived rules, so later rules win on +conflicts. ### Next.js Integration -**Section**: `[integrations.nextjs]` +**Section**: `[integration.nextjs]` | Field | Type | Default | Contract | | ---------------------------- | ------------- | ------------------------- | -------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable Next.js integration | | `rewrite_attributes` | Array[String] | `["href", "link", "url"]` | Nonempty set of structured payload keys to rewrite | | `max_combined_payload_bytes` | Integer | `10485760` | Maximum combined RSC payload size in bytes | **Example**: ```toml -[integrations.nextjs] -enabled = true +[integration] +provider = ["nextjs"] + +[integration.nextjs] rewrite_attributes = ["href", "link", "url", "src"] max_combined_payload_bytes = 10485760 ``` @@ -1818,42 +2010,33 @@ max_combined_payload_bytes = 10485760 **Environment Override**: ```bash -TRUSTED_SERVER__INTEGRATIONS__NEXTJS__ENABLED=true -TRUSTED_SERVER__INTEGRATIONS__NEXTJS__MAX_COMBINED_PAYLOAD_BYTES=10485760 +TRUSTED_SERVER__INTEGRATION__NEXTJS__MAX_COMBINED_PAYLOAD_BYTES=10485760 ``` Edit `rewrite_attributes` in TOML because the overlay cannot replace arrays. ### Osano Integration -**Section**: `[integrations.osano]` +**Section**: `[integration.osano]` -| Field | Type | Default | Description | -| --------- | ------- | ------- | --------------------------------------- | -| `enabled` | Boolean | `false` | Enable the Osano browser consent mirror | +Osano has nothing to set, so naming it in `[integration] provider` is the whole +configuration and it needs no table. **Example**: ```toml -[integrations.osano] -enabled = true -``` - -**Environment Override**: - -```bash -TRUSTED_SERVER__INTEGRATIONS__OSANO__ENABLED=true +[integration] +provider = ["osano"] ``` The Osano mirror runs in the browser, so consent cookies it writes are available to Trusted Server on requests after the page where Osano consent APIs become ready. See [Osano Integration](/guide/integrations/osano) for details. ### Permutive Integration -**Section**: `[integrations.permutive]` +**Section**: `[integration.permutive]` | Field | Type | Default | Contract | | ------------------------- | ------- | -------------------------------------- | -------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Permutive integration | | `organization_id` | String | Required | Nonempty Permutive organization ID | | `workspace_id` | String | Required | Nonempty Permutive workspace ID | | `project_id` | String | `""` | Optional project ID; reserved for future use | @@ -1865,8 +2048,10 @@ The Osano mirror runs in the browser, so consent cookies it writes are available **Example**: ```toml -[integrations.permutive] -enabled = true +[integration] +provider = ["permutive"] + +[integration.permutive] organization_id = "org-12345" workspace_id = "ws-67890" project_id = "proj-abcde" @@ -1878,7 +2063,7 @@ rewrite_sdk = true ### Sourcepoint Integration -**Section**: `[integrations.sourcepoint]` +**Section**: `[integration.sourcepoint]` | Field | Type | Default | Contract | | ------------------- | -------------- | ------------------------------ | ----------------------------------------------------------------- | @@ -1892,11 +2077,10 @@ See [Sourcepoint](/guide/integrations/sourcepoint). ### Testlight Integration -**Section**: `[integrations.testlight]` +**Section**: `[integration.testlight]` | Field | Type | Default | Contract | | ----------------- | ------- | ---------------------------------- | ----------------------------------- | -| `enabled` | Boolean | `false` | Enable Testlight integration | | `endpoint` | URL | Required | Testlight auction endpoint | | `timeout_ms` | Integer | `1000` | `10..=60000` milliseconds | | `shim_src` | String | `/static/tsjs=tsjs-unified.min.js` | Nonempty script source for the shim | @@ -1905,8 +2089,10 @@ See [Sourcepoint](/guide/integrations/sourcepoint). **Example**: ```toml -[integrations.testlight] -enabled = true +[integration] +provider = ["testlight"] + +[integration.testlight] endpoint = "https://testlight.example/openrtb2/auction" timeout_ms = 1500 rewrite_scripts = true @@ -1914,10 +2100,11 @@ rewrite_scripts = true ## Auction Configuration -`[auction.providers.*]` is the only server-side provider inventory, and -`[auction.bidders.*]` is the only client-visible bidder route map. The optional -`[auction].mediator` remains a separate integration selection; it is not a -provider or bidder route. +An auction is configured by three tables. `[demand]` selects the demand sources +and gives each its settings, `[adserver]` selects the ad server that picks the +winner, and `[auction]` holds the settings that belong to the auction itself, +including `[auction.bidders.]`, the only client-visible bidder route map. +`[auction]` is not a provider type and takes no `provider` key. ### `[auction]` @@ -1927,8 +2114,7 @@ provider or bidder route. | `sanitize_creatives` | Boolean | `false` | Strip executable markup from winning-bid `adm` before delivery | | `rewrite_creatives` | Boolean | `true` | Rewrite winning-bid `adm` through first-party endpoints | | `timeout_ms` | Integer | `2000` | Logical auction budget in milliseconds | -| `mediator` | String | `None` | Optional separate `adserver_mock` mediator | -| `creative_store` | String | `"creative_store"` | Deprecated; creatives are delivered inline | +| `creative_store` | String | `"creative_store"` | Deprecated, because creatives are delivered inline | | `allowed_context_keys` | Array | `[]` | Request context keys admitted into the auction | Creative markup delivered by `POST /auction` and the publisher SSAT/page-bids @@ -1951,7 +2137,7 @@ setting affects HTML or CSS fetched through `/first-party/proxy`. See [Creative Processing](/guide/creative-processing#auction-rewrite-control). ::: warning Existing configs, upgrade sequencing, and rollback -Default values are omitted from stored JSON; non-default values +Default values are omitted from stored JSON. Non-default values (`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized, and older `AuctionConfig` schemas reject unknown fields. @@ -1977,42 +2163,51 @@ leaves. Existing configs must add **both** leaves under `[auction]` missing leaf is silently ignored. ::: -### Provider map - -::: danger Breaking migration from the provider list -The former `[auction].providers = ["prebid", ...]` list and server-owned fields -under `[integrations.prebid]` and `[integrations.aps]` are no longer accepted, -even when an integration is disabled. Replace them with provider instances and -bidder routes before deployment. - -For Prebid Server, move `server_url` to provider `endpoint`, server timeout to -provider `timeout_ms`, request controls and bidder-parameter overrides to the -`prebid-server` `profile_config`, notification suppression to `notifications`, -and each server bidder to `[auction.bidders.]`. Origin-only legacy -`server_url` values compile to `/openrtb2/auction`; query parameters survive, -and configured non-root custom endpoint paths remain exact. Browser timeout, -debug, bundle, script interception, refresh exclusions, and -`client_side_bidders` remain under `[integrations.prebid]`. Configure timeout or -debug under both owners when both browser and server behavior should retain the -old value. - -For APS, move endpoint and timeout to the provider, then move account, -inventory, debug, and creative controls to the `aps` `profile_config`. +### Demand sources + +::: danger Breaking migration from `[auction.providers]` +`[auction] providers` and `[auction.providers.]` are gone, and a +configuration still carrying either is refused with a message naming where the +setting moved to. Server-owned fields under `[integrations.prebid]` and +`[integrations.aps]` are gone with them. + +Move each provider to a `[demand.]` table. `protocol` disappears, +because every implementation states its own wire format. `profile` becomes +`implementation`, so `"standard"` becomes `"openrtb"`, `"prebid-server"` +becomes `"prebid_server"`, and `"aps"` stays `"aps"`. Everything that was +inside `profile_config` moves up into the table itself, flat beside +`endpoint`, `timeout_ms`, `routing` and `notifications`. Provider IDs that +carried a hyphen, such as `pbs-main`, become snake_case, such as `pbs_main`, +and so does every `[auction.bidders.] provider` value that points at +one. + +For Prebid Server, move `server_url` to `endpoint` and the server timeout to +`timeout_ms`. Origin-only legacy `server_url` values compile to +`/openrtb2/auction`, query parameters survive, and configured non-root custom +endpoint paths remain exact. Browser timeout, debug, bundle, script +interception, refresh exclusions and `client_side_bidders` stay under +`[integration.prebid]`. Configure timeout or debug under both owners when both +browser and server behavior should retain the old value. + +For APS, move the endpoint and timeout to the table, then move account, +inventory, debug and creative controls up beside them. `rendering_mode` moves +out of `[integrations.aps]` into the same table. Only bidder codes listed in `[auction.bidders]` are folded into Trusted Server -requests. Unlisted publisher bids remain native browser demand. All provider -endpoints must be absolute HTTPS URLs. +requests. Unlisted publisher bids remain native browser demand. The old and new blobs are mutually incompatible. Activate the new binary and -map-shaped config together. A binary-first or config-first rolling deployment -will put one version on a schema it rejects. Roll back by restoring the old -binary and old-schema blob together. +the new-shape config together. A binary-first or config-first rolling +deployment will put one version on a schema it rejects. Roll back by restoring +the old binary and old-schema blob together. ::: -Each table name is the provider ID used for configuration, backend correlation, -health, response metadata, and telemetry. Provider IDs must match -`^[a-z][a-z0-9-]{0,62}$`. Multiple instances may select the same profile and -endpoint because the provider ID remains their distinct runtime identity. +`[demand] provider` lists the demand sources, in a list because several run. +Each table name is the demand source's identity for configuration, backend +correlation, health, response metadata and telemetry, and must be snake_case. +The name is the implementation unless the table carries an `implementation` +line, which is how two Prebid Servers run side by side under names of their +own. **Example**: @@ -2022,108 +2217,133 @@ enabled = true sanitize_creatives = false rewrite_creatives = true timeout_ms = 2000 -mediator = "adserver_mock" -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" routing = "explicit" timeout_ms = 1200 - -[auction.providers.pbs-main.profile_config] debug = false test_mode = false consent_forwarding = "both" -[auction.providers.pbs-main.notifications] +[demand.pbs_main.notifications] suppress_all = false suppress_seats = ["example-seat"] -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" - -[auction.providers.aps-main.profile_config] account_id = "example-aps-account" debug = false allow_script_creatives = false [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" -[integrations.adserver_mock] -enabled = true -endpoint = "https://mediator.example.com/mediate" +[adserver] +provider = "adserver_mock" + +[adserver.adserver_mock] +endpoint = "https://adserver.example.com/decide" timeout_ms = 500 ``` -| Provider field | Required | Default | Description | -| ---------------- | -------- | --------------- | ------------------------------------------------------------- | -| `protocol` | Yes | None | Must be `openrtb-2.6` | -| `profile` | No | `standard` | `standard`, `prebid-server`, or `aps` | -| `endpoint` | Yes | None | Absolute HTTPS URL with host and no credentials or fragment | -| `timeout_ms` | No | Profile default | Provider logical budget before the remaining-auction cap | -| `routing` | No | `explicit` | `explicit`, or `all_eligible` for non-PBS profiles | -| `profile_config` | No | `{}` | Typed object owned by the selected profile | -| `notifications` | No | No suppression | Common `nurl`/`burl` suppression after response normalization | - -### Profile configuration - -The table reflects the typed profile schemas. `Required` refers to the selected -profile's `profile_config` object, not to the provider wrapper. - -| Profile | Field | Required | Default | Provider timeout default | Constraints | -| --------------- | -------------------------- | -------- | ------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| `aps` | `account_id` | Yes | — | `800 ms` | String or integer; trimmed, nonempty, at most 1024 bytes | -| `aps` | `allow_script_creatives` | No | `false` | `800 ms` | Script creatives are ineligible unless enabled | -| `aps` | `debug` | No | `false` | `800 ms` | May expose unredacted request and response data | -| `aps` | `inventory_domain` | No | `None` | `800 ms` | DNS name, at most 253 bytes; configure with inventory_page_origin | -| `aps` | `inventory_page_origin` | No | `None` | `800 ms` | HTTPS origin without credentials, port, path, query, or fragment; host must equal or be beneath inventory_domain | -| `prebid-server` | `bid_param_override_rules` | No | `[]` | `1000 ms` | Ordered exact-match rules; at least one matcher and a nonempty set object | -| `prebid-server` | `bid_param_overrides` | No | `{}` | `1000 ms` | Per-bidder nonempty shallow-merge objects | -| `prebid-server` | `bid_param_zone_overrides` | No | `{}` | `1000 ms` | Per-bidder, per-zone nonempty shallow-merge objects | -| `prebid-server` | `consent_forwarding` | No | `both` | `1000 ms` | `openrtb_only`, `cookies_only`, or `both` | -| `prebid-server` | `debug` | No | `false` | `1000 ms` | Includes upstream exchange diagnostics | -| `prebid-server` | `debug_query_params` | No | `None` | `1000 ms` | Optional legacy page-URL query fragment | -| `prebid-server` | `test_mode` | No | `false` | `1000 ms` | Sets OpenRTB `test = 1` | -| `standard` | `imp_ext` | No | `{}` | Auction timeout | JSON object; at most 16384 bytes, depth 8, and 256 keys per object | -| `standard` | `request_ext` | No | `{}` | Auction timeout | JSON object; at most 16384 bytes, depth 8, and 256 keys per object; `trusted_server` is reserved | - -Timeout defaults are 1000 ms for `prebid-server`, 800 ms for `aps`, and the -auction timeout for `standard`. An explicit provider timeout overrides the -profile default. Runtime uses `min(provider timeout, auction time remaining)` -for launch decisions and OpenRTB `tmax`. +Every `[demand.]` table takes these four settings, whichever +implementation it names: + +| Setting | Required | Default | Description | +| --------------- | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `endpoint` | Yes | None | Absolute HTTPS URL with a host and no credentials or fragment. Plain HTTP is accepted only to `127.0.0.1`, `::1` or `localhost` | +| `timeout_ms` | No | Implementation default | This source's logical budget before the remaining-auction cap | +| `routing` | No | `explicit` | `explicit`, or `all_eligible` where the implementation allows it | +| `notifications` | No | No suppression | Common `nurl`/`burl` suppression after response normalization | + +Every other key in the table belongs to the implementation, which rejects any +key it does not know. + +| Implementation | Default timeout | `all_eligible` | Its own settings | +| --------------- | ------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `openrtb` | the auction budget | yes | `request_ext`, `imp_ext` | +| `prebid_server` | 1000 ms | no | `debug`, `test_mode`, `debug_query_params`, `consent_forwarding`, `bid_param_overrides`, `bid_param_zone_overrides`, `bid_param_override_rules` | +| `aps` | 800 ms | yes | `account_id` (required), `debug`, `allow_script_creatives`, `inventory_domain`, `inventory_page_origin`, `rendering_mode` | + +An explicit `timeout_ms` overrides the implementation default. Runtime uses +`min(source timeout, auction time remaining)` for launch decisions and OpenRTB +`tmax`. `routing = "explicit"` sends only slots carrying a bidder assigned to that -provider, plus trusted stored-request routes. `routing = "all_eligible"` sends -every banner-compatible slot to the provider, regardless of bidder routes. It -does not disclose bidder parameters assigned to another provider. APS commonly +source, plus trusted stored-request routes. `routing = "all_eligible"` sends +every banner-compatible slot to the source, regardless of bidder routes. It +does not disclose bidder parameters assigned to another source. APS commonly uses `all_eligible` to preserve its whole-inventory participation. The -`prebid-server` profile rejects `all_eligible` because every PBS impression must -carry routed bidder or stored-request demand. +`prebid_server` implementation rejects `all_eligible` because every PBS +impression must carry routed bidder or stored-request demand. + +APS `rendering_mode` is `trusted_server` by default, which renders through +Trusted Server's opaque static renderer route. Set `publisher_native` only for +a controlled publisher-origin friendly-frame cohort. + +### Ad server + +`[adserver] provider` names the one ad server that picks the winner, as a +string rather than a list, and `[adserver.]` holds its settings. With no +ad server the orchestrator selects the highest decoded CPM per slot and +applies floors locally. With one configured, normalized demand responses are +sent to it, and Trusted Server falls back to local ranking when the ad server +cannot run. + +The one implementation this repository ships is `adserver_mock`, for +development and testing. + +| Setting | Required | Default | Description | +| ---------------------- | -------- | ------- | ------------------------------------------------------------------- | +| `endpoint` | Yes | None | Decision endpoint URL, on the same scheme rule as a demand endpoint | +| `timeout_ms` | No | `500` | Request timeout, 1 to 60000 | +| `price_floor` | No | None | Minimum acceptable CPM | +| `context_query_params` | No | `{}` | Maps auction context keys to decision-URL query parameters | + +```toml +[adserver] +provider = "adserver_mock" + +[adserver.adserver_mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 + +[adserver.adserver_mock.context_query_params] +example_segments = "segments" +``` + +The word mediator is gone. It is "ad server" in prose and `adserver` in +configuration, `[debug.auction_html_comment_options] include_mediator_response` +is now `include_adserver_response`, and the auction response metadata that read +`parallel_mediation` now reads `parallel_adserver`. ### Bidder routes and bounds -Each `[auction.bidders.]` maps one client-visible bidder ID to exactly -one provider. Bidder IDs must be nonempty, no more than 128 UTF-8 bytes, contain -no control characters or surrounding whitespace, and cannot be the reserved -exact ID `trustedServer`. Browser `trustedServer.bidderParams` accepts at most -128 bidder entries; its optional `zone` is at most 256 UTF-8 bytes. +Each `[auction.bidders.]` maps one client-visible bidder ID to +exactly one demand source, named by its `[demand]` table name. A route naming +a source `[demand] provider` does not select is refused. Bidder IDs must be +nonempty, no more than 128 UTF-8 bytes, contain no control characters or +surrounding whitespace, and cannot be the reserved exact ID `trustedServer`. +Browser `trustedServer.bidderParams` accepts at most 128 bidder entries, and +its optional `zone` is at most 256 UTF-8 bytes. -For the `standard` profile, `profile_config.request_ext` and `imp_ext` must be -JSON objects. Each object is limited to 16 KiB serialized, eight container -levels, and 256 keys at any one object level. Within `request_ext`, the -`trusted_server` member is reserved and cannot be overwritten. `imp_ext` has no -reserved-member guard in the current implementation. +For the `openrtb` implementation, `request_ext` and `imp_ext` must be JSON +objects. Each object is limited to 16 KiB serialized, eight container levels, +and 256 keys at any one object level. Reserved driver, implementation and +signing fields cannot be overwritten. Common notification suppression uses exact returned OpenRTB seat values, not bidder route IDs: ```toml -[auction.providers.pbs-main.notifications] +[demand.pbs_main.notifications] suppress_all = false suppress_seats = ["example-seat"] ``` @@ -2134,51 +2354,57 @@ UTF-8 bytes and without ASCII control characters. ### Validation timing and target limits `ts config validate` and ordinary deploy validation compile the complete -target-independent plan: profiles and defaults, routes, endpoint ownership, -extension bounds, notifications, signing structure, and mediator selection. -Target-specific checks are deferred to adapter startup. Startup uses the same -compiled plan and additionally validates backend-name prediction/collisions and -provider fan-out capability. - -Fastly and Axum support multiple configured providers. Cloudflare and Spin -currently reject an enabled auction with more than one provider because those -adapters do not support concurrent provider fan-out. Disabled auctions may keep -dormant multi-provider maps without target rejection. +target-independent plan from `[demand]`, `[adserver]` and `[auction.bidders]`. +That covers unselected tables, names that are not snake_case, an +implementation this build does not have, endpoint scheme and host, timeouts, +routing modes, notification bounds, bidder route ownership, signing structure, +and any setting the chosen implementation rejects. Target-specific checks are +deferred to adapter startup. Startup uses the same compiled plan and +additionally validates backend-name prediction and collisions, and demand +fan-out capability. + +Fastly and Axum support several configured demand sources. Cloudflare and Spin +currently reject an enabled auction with more than one, because those adapters +do not support concurrent fan-out. A disabled auction may keep a dormant +multi-source plan without target rejection. A target-aware pre-write `ts config push --adapter ` callback is **not available in this tree** because the required EdgeZero callback is not yet available. Until it lands, push performs target-independent validation and adapter startup is the mandatory target-aware gate. Do not treat a successful -push as proof that a Cloudflare or Spin multi-provider plan can start. +push as proof that a Cloudflare or Spin multi-source plan can start. ### Deadline behavior Configured timeouts are logical budgets, not hard wall-clock guarantees. No -current adapter claims an abortable provider-wide total-request deadline. -Already-launched work may complete after the logical budget and a completed late -response can remain eligible. Once the logical auction budget is exhausted, -Trusted Server starts no additional provider or mediator network work, then -finishes local decision and delivery. An auction can therefore exceed its -configured wall-clock timeout. +current adapter claims an abortable total-request deadline across demand +sources. Already-launched work may complete after the logical budget and a +completed late response can remain eligible. Once the logical auction budget is +exhausted, Trusted Server starts no additional demand or ad server network +work, then finishes local decision and delivery. An auction can therefore +exceed its configured wall-clock timeout. Creative sanitization is opt-in. `sanitize_creatives = true` strips executable markup before delivery. `rewrite_creatives = false` skips first-party URL rewriting and creative TSJS injection. See [Creative Processing](/guide/creative-processing#auction-rewrite-control). -**Environment overrides** replace map leaves that already exist in TOML: +**Environment overrides** replace scalar leaves that already exist in TOML: ```bash env 'TRUSTED_SERVER__AUCTION__ENABLED=true' \ 'TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES=false' \ 'TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES=true' \ 'TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000' \ - 'TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__ENDPOINT=https://prebid.example.com/openrtb2/auction' \ - 'TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__TIMEOUT_MS=900' \ - 'TRUSTED_SERVER__AUCTION__MEDIATOR=adserver_mock' \ + 'TRUSTED_SERVER__DEMAND__PBS_MAIN__ENDPOINT=https://prebid.example.com/openrtb2/auction' \ + 'TRUSTED_SERVER__DEMAND__PBS_MAIN__TIMEOUT_MS=900' \ ts config validate ``` +A `provider` list is an array, so `[demand] provider` and +`[integration] provider` cannot be changed by an overlay. Edit the TOML, then +validate and push. + ## Creative Opportunities Configuration ### `[creative_opportunities]` @@ -2221,7 +2447,7 @@ enabled = true # set to false to disable server-side ad templates gam_network_id = "123456789" price_granularity = "dense" -# Shared placeholder value for the site root ("/") — see {section} below. +# Shared placeholder value for the site root ("/"). See {section} below. section_root = "home" # Which path segment names the section, 0-based. Default 0 (first segment). # Set to 1 for locale-prefixed URLs such as "/en/news/article". @@ -2231,7 +2457,7 @@ section_root = "home" id = "ad-header" gam_unit_path = "/{network_id}/example/{section}" # List each section landing page as well as its subtree: `/news/*` matches -# `/news/article` but NOT `/news` — the glob requires the trailing separator. +# `/news/article` but NOT `/news`, because the glob requires the trailing separator. page_patterns = ["/", "/news", "/news/*", "/reviews", "/reviews/*"] formats = [{ width = 728, height = 90 }] ``` @@ -2449,9 +2675,9 @@ fresh origin response; they do not fail the page. The corresponding `X-TS-Assembly` identifies how the private response was assembled: -- `esi-parser` — authorized cold miss assembled by the repaired parser; -- `byte-seam` — warm template-cache hit using the streaming byte seam; -- `byte-seam-fallback` — cold response safely assembled by byte seam because +- `esi-parser`, an authorized cold miss assembled by the repaired parser; +- `byte-seam`, a warm template-cache hit using the streaming byte seam; +- `byte-seam-fallback`, a cold response safely assembled by byte seam because the platform parser was unavailable or rejected the document. The two headers together are the reliable verification signal. Timing alone can @@ -2521,12 +2747,12 @@ dynamic-only limit. - Casing is preserved. [Google documents GAM ad-unit codes as case-insensitive](https://support.google.com/admanager/answer/10477476?hl=en), so do not lowercase the value. -- The path is used **raw — it is not percent-decoded**. So `/new%20s` → +- The path is used **raw, and is not percent-decoded**. So `/new%20s` → `new_20s` (only `%` is disallowed; `2` and `0` are kept), never the decoded `new_s`. This keeps `{section}` consistent with how `page_patterns` match the same raw path. -- When the path has no segment at that index — the site root (`/`, or repeated - slashes), or a path shorter than `section_segment` — `{section}` is +- When the path has no segment at that index, being the site root (`/`, or + repeated slashes) or a path shorter than `section_segment`, `{section}` is `section_root`. So with `section_segment = 1`, the path `/en` renders the root section rather than reusing the locale. @@ -2573,7 +2799,7 @@ The same config with `section_segment = 1` and locale-prefixed patterns | `/en/news` | `/123456789/example/news` | | `/en/news/article` | `/123456789/example/news` | -An **unmatched route** — a path matched by no slot's `page_patterns` — produces +An **unmatched route**, a path matched by no slot's `page_patterns`, produces no slot at all, so no template is rendered for it. Startup validation rejects a malformed template: an unknown placeholder (e.g. @@ -2644,46 +2870,70 @@ version if that is required. ## Validation -### Automatic Validation +Configuration is checked at two gates. +[Configuration Rules](/guide/configuration-rules#what-is-checked-before-a-request-is-served) +sets out which rule is caught where. In short, `ts config validate`, +`ts config diff` and `ts config push` check everything that can be decided +from the file, and startup checks the rest and runs the first set again. -Configuration is validated at startup: +### Checked when the configuration is validated or pushed -**Publisher Validation**: +**Publisher**: - All fields non-empty -- `origin_url` is valid URL +- `origin_url` is a valid URL **EC Validation**: -- The `passphrase` key name is non-empty at push time -- The resolved passphrase is at least 32 bytes at runtime -- Known placeholder values are rejected after resolution +- `provider`, when set, is `snake_case` and has the `[ec.]` table its + implementation needs, and no unselected table is left configured, or startup + fails +- The `hmac.passphrase` key name is non-empty at push time, the resolved + passphrase is at least 32 bytes at runtime, and a known placeholder value is + rejected after resolution +- The complete auction plan compiles from `[demand]`, `[adserver]` and + `[auction.bidders]`, so an unselected table, a name that is not snake_case, + an implementation this build does not have, a bad endpoint, an out-of-range + timeout, an unsupported routing mode, a route naming an unselected demand + source, or a setting the implementation rejects, all fail here + +**Secrets**: -**Handler Validation**: +- Every secret setting holds a key name and no secret value -- `path` is valid regex +**Handlers**: + +- `path` is a valid regex - `username` is ordinary configuration and non-empty -- The resolved `password` is non-empty and is checked for placeholders at runtime +- At least one handler covers the `/_ts/admin` namespace -**Integration Validation**: +**Integrations**: -- Each integration implements `Validate` trait -- Custom rules per integration +- Each integration validates its own block, selected or not, so a typo in a + block that is switched off is still caught -### Validation Errors +### Checked when the service starts -**Startup Failure** if: +Everything above runs again on the loaded configuration, and these join it: -- Required fields missing -- Invalid data types -- Regex compilation fails -- Secret key is default value -- Integration config fails validation +- The `[ec]`, `[geo]`, `[device]` and `[permission_signal]` selections. A name + this build does not have, a missing settings table, or a table the selector + does not name, stops the service on its next start. A passing + `ts config validate` is not proof that a change to those four will start +- Resolved secret values, so a passphrase shorter than 32 bytes, a placeholder + or a weak handler password fails here +- The compiled `permissions.yaml` policy, and the `assume_single_jurisdiction` + acknowledgment an Edge Cookie provider needs when no geo provider is selected +- The checks only the host can make, being backend name prediction and + collisions, and whether the adapter can call more than one demand source at + once + +### Validation Errors **Error Format**: ``` -Configuration error: provider `pbs-main` endpoint must be an absolute HTTPS URL +Configuration error: [demand.pbs_main] endpoint must be HTTPS, or HTTP to 127.0.0.1, ::1 or localhost, with a host and no credentials or fragment ``` ## Best Practices diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 10dcb62d5..2bd860a32 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -819,7 +819,7 @@ Each integration is built as a separate IIFE at compile time (`crates/trusted-se - Prebid is built externally with `build-prebid-external.mjs` and served through `/integrations/prebid/bundle.js` - `tsjs-lockr.js`, `tsjs-permutive.js`, `tsjs-didomi.js`, `tsjs-datadome.js`, `tsjs-testlight.js` — Other integrations -At runtime, the server concatenates `tsjs-core.js` + the modules for enabled integrations. The URL stays `/static/tsjs=tsjs-unified.min.js?v=` for backward compatibility. +At runtime, the server concatenates `tsjs-core.js` + the modules for the integrations that run. The URL stays `/static/tsjs=tsjs-unified.min.js?v=` for backward compatibility. ## Performance Optimization diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index 3ec1d0441..ed8c3ae36 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -25,9 +25,12 @@ The `replace-with-*` values below are intentionally rejected placeholders: ```toml [ec] -passphrase = "ec_passphrase" +provider = "hmac" ec_store = "ec_identity_store" +[ec.hmac] +passphrase = "ec_passphrase" + [[ec.partners]] name = "Mocktioneer SSP" source_domain = "formally-vital-lion.edgecompute.app" @@ -40,10 +43,11 @@ secret store, not the credential values. This workflow calls the inbound identify and batch-sync APIs, so its partner needs `api_token`. Partners that do not call either API may omit it. Provision high-entropy values under `ec_passphrase` and `partner_api_token`; see -[Configuration](/guide/configuration#secret-store-migration). +[Configuration](/guide/configuration#static-secret-references). Required behavior assumptions: +- `provider = "hmac"` selects the built-in HMAC provider, and its `passphrase` lives under `[ec.hmac]` - The value stored under `ec_passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters - `ec_store` is linked to the active Fastly service version - `ec_store` is the only KV-backed EC lifecycle store; it contains identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones @@ -99,13 +103,13 @@ curl -si "${TS_BASE_URL}/" \ Look for: -- `Set-Cookie: ts-ec=<64hex.6chars>` +- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the provider code; pre-series cookies without it still resolve) ## 5) Batch Sync (S2S) Endpoint: `POST /_ts/api/v1/batch-sync` -Important: request field is `ec_id` (full `{64hex}.{6alnum}` value). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Valid mappings are idempotent last-write-wins: unchanged UIDs are accepted without a write, and different UIDs replace the stored value. +Important: request field is `ec_id` (the full value as issued, `hmac~{64hex}.{6alnum}`; the bare pre-series form is also accepted). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Valid mappings are idempotent last-write-wins: unchanged UIDs are accepted without a write, and different UIDs replace the stored value. ```bash BATCH_UID="${PARTNER_UID}-batch" diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 2ba9f2638..d06253445 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -8,12 +8,9 @@ permit EC use. ## Policy Posture -Trusted Server is technology. It is neutral on policy. The Edge Cookie -gives the deployer a cookie slot and configuration over the surrounding -attributes. The deployer determines the policy posture based on the -laws and contractual arrangements that apply to their deployment. -Privacy outcomes follow from that configuration, not from the cookie -mechanism itself. +Trusted Server is technology. It is neutral on policy. The Edge Cookie gives the deployer a cookie slot and configuration over the surrounding attributes. The deployer determines the policy posture based on the laws and contractual arrangements that apply to their deployment. Privacy outcomes follow from that configuration, not from the cookie mechanism itself. + +An Edge Cookie (EC) is a first-party identifier that the built-in provider derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server surfaces the current EC ID via response headers and a first-party cookie. For the exact header and cookie names, see the [API Reference](/guide/api-reference). For full operational onboarding (partner configuration, batch sync, identify, and auction verification), use the [EC Setup Guide](/guide/ec-setup-guide). @@ -42,7 +39,7 @@ maps to a stable base. ### Request Lifecycle -Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction) — read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds consent from request-local cookies, headers, geolocation, and policy defaults; it does not load consent from a separate KV store. +Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction). Read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds the consent context from request-local cookies, headers, geolocation, and policy defaults. It does not load consent from a separate KV store. ```mermaid sequenceDiagram @@ -60,7 +57,7 @@ sequenceDiagram Note over TS: Phase 3: Finalize
Ingest Prebid EID cookies TS-->>B: Response + Set-Cookie: ts-ec=... else Return Visit (EC cookie present) - Note over TS: Phase 2: Routing
EC exists — skip generation + Note over TS: Phase 2: Routing
EC exists, skip generation Note over TS: Phase 3: Finalize
Ingest Prebid EID cookies TS-->>B: Response
(no cookie refresh) end @@ -70,11 +67,11 @@ sequenceDiagram ### Response Finalization -After routing completes, the server evaluates consent state and cookie presence to decide what to do with the EC cookie on the response. +After routing completes, the server evaluates the permission state and cookie presence to decide what to do with the EC cookie on the response. ```mermaid flowchart TD - Start[ec_finalize_response] --> ConsentCheck{Consent
allows EC?} + Start[ec_finalize_response] --> ConsentCheck{Permissions
allow EC?} ConsentCheck -- "No" --> ExplicitWithdrawal{Explicit
withdrawal?} ExplicitWithdrawal -- "Yes" --> CookiePresent{Cookie was
present?} @@ -87,44 +84,71 @@ flowchart TD WasPresent -- "No, just generated" --> NewEc["Ingest Prebid EID cookies
Set ts-ec cookie"] ``` -When consent cannot be verified for the current request — for example, unknown jurisdiction or missing/undecodable consent signals in a regulated region — Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. +When the required permissions cannot be established for the current request (for example an unknown country with no configured default, or missing or undecodable consent signals), Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. + +## Permission Gating + +EC creation is gated through the [permission model](/guide/permission-model), not by a jurisdiction rule baked into the core. The Edge Cookie provider advertises the permissions its data use requires, and Trusted Server creates an Edge Cookie only when every required permission is set. The built-in HMAC provider requires `necessary.operations.storage` (TCF Purpose 1), because the `Set-Cookie` operation stores information on the device. + +The Edge Cookie code never reads consent. It checks only whether the required **permission** is set. Consent is one of the sources that _set_ a permission, not something the gate reads directly, so the Edge Cookie logic does not change when a consent framework changes. Two sources combine for each request: + +- **A country and region baseline.** The country, and an optional region such as a US state, that the geo provider returns. A region rule takes precedence over its country, and when no country is identified, or the country/region has no rule, the configured default country applies. +- **Consent and privacy signals.** TCF, GPP, and GPC (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`) decoded from the request and mapped onto permissions as a **grant or a revoke** on top of that baseline. There is no separate consent KV fallback. + +Today only `necessary.operations.storage` is resolved this way: its country and region baseline is adjusted by the incoming TCF signal, and the Edge Cookie is created only when the result is set. With no configured default country, an unknown country sets nothing without a signal, so the cookie is not created unless a signal grants the permission. The core encodes no jurisdiction's law. The deployer brings the policy, and the per-country and per-region rules are configuration rather than core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. + +```mermaid +flowchart TD + Start[Resolve country and region] --> Baseline[Country or region rule,
else the default country] + Baseline --> Signals[Apply consent/privacy signals
as a grant or revoke] + Signals --> Check{Provider's required
permissions all set?} + Check -- "Yes" --> Allow([Create EC]) + Check -- "No" --> Deny([No EC]) +``` + +The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, and withdrawal tombstones. Permission resolution for each request is based on the live request signals listed above. + +## Provider Types: Server-Side and Client-Side -## Consent Model +The Edge Cookie identifier comes from a configurable provider, selected by `[ec] provider`. A provider is one of two types, and the permission gate above applies to both. The two reach the **same outcome** (a `ts-ec` cookie set and carried on every later request) by **different routes**. -EC creation is gated by jurisdiction. The server detects jurisdiction from geolocation data attached to the request and applies the corresponding consent rules. Live consent comes from request-local signals (`euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy`, `Sec-GPC`) plus geolocation and policy defaults; there is no separate consent KV fallback. +- **Server-side** (for example the built-in HMAC provider, or the built-in `host_signals` provider that derives an identifier from the host's TLS JA4 and HTTP/2 signals on a host that supplies them). The provider derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved. +- **Client-side** (for example the `client_fixed` demonstration provider). The provider cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the provider's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The provider derives an identifier from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. ```mermaid flowchart TD - Start[Detect Jurisdiction] --> J{Jurisdiction?} - - J -- "GDPR
(EU/UK)" --> TCF{TCF string
present?} - TCF -- "Yes" --> P1{Purpose 1
granted?} - P1 -- "Yes" --> Allow([Allow EC]) - P1 -- "No" --> Deny([Deny EC]) - TCF -- "No" --> Deny - - J -- "US State" --> GPC{GPC header
set?} - GPC -- "Yes" --> Deny - GPC -- "No" --> USTCF{TCF from CMP
e.g. Didomi?} - USTCF -- "Yes" --> USP1{Purpose 1
granted?} - USP1 -- "Yes" --> Allow - USP1 -- "No" --> Deny - USTCF -- "No" --> USP{US Privacy
string?} - USP -- "Yes" --> OptOut{Opt-out
sale?} - OptOut -- "No" --> Allow - OptOut -- "Yes" --> Deny - USP -- "No" --> Deny - - J -- "Non-regulated" --> Allow - J -- "Unknown
(no geo data)" --> Deny + Start(["Page request, no Edge Cookie"]) --> Type{"Provider type"} + + Type -->|"Server-side (e.g. HMAC)"| SGen["generate() derives at the edge from request data"] + SGen --> SSet["Page response sets ts-ec"] + + Type -->|"Client-side (e.g. client_fixed)"| CDefer["generate() defers, returns no identifier"] + CDefer --> CPage["Page response, no cookie, delivers the provider JS"] + CPage --> CBox[["Provider JS (black box): runs in the browser and does its work"]] + CBox --> CPost["JS posts the result to POST /_ts/api/v1/ec/resolve"] + CPost --> CResolve["resolve_from_client() verifies and derives"] + CResolve --> CSet["Resolve response sets ts-ec"] + + SSet --> Same(["Same outcome: ts-ec set, carried on every later request"]) + CSet --> Same ``` -- **GDPR**: Opt-in required. TCF Purpose 1 (store/access device) must be explicitly consented. -- **US State**: Opt-out model with three-tier fallback — GPC always blocks, then TCF if a CMP uses it, then US Privacy string, then fail-closed. -- **Non-regulated**: EC always allowed. -- **Unknown**: Fail-closed when jurisdiction cannot be determined. +The two types differ only in route and in the methods they use: + +| Feature | Server-side | Client-side | +| -------------------- | ------------------------- | ---------------------------------------------- | +| Example | HMAC (`hmac`) | `client_fixed` (demonstration) | +| Created in | `generate()`, at the edge | `resolve_from_client()`, from the posted value | +| `generate()` returns | the identifier | no identifier (defers) | +| Client JavaScript | none | the provider JS (black box), which posts back | +| Endpoint | none | `POST /_ts/api/v1/ec/resolve` | +| Cookie set on | the page response | the resolve response | + +The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back, and a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client_fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. + +Because the posted value comes from the browser, **verification is the provider's responsibility**. A client-side provider must verify the payload (for example a signature) before creating an identifier, or a client could forge an Edge Cookie. The endpoint itself is provider-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the provider, and writes the cookie. -The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, and withdrawal tombstones. Consent interpretation for each request remains based on the live request signals listed above. +A built-in `client_fixed` provider demonstrates the client-side type end to end with no vendor coupling. Client and server share one fixed, known word. When no Edge Cookie is present, the page script (shipped in the tsjs bundle when that provider is selected) posts that word, the server verifies it matches, and on a match sets it as the Edge Cookie. The value is verifiable because it is a known constant, which is the point of the demo. It is useless in production, because a fixed value is not an identity, so it is for demonstration and testing only. ## Partner Sync Channels @@ -285,7 +309,7 @@ sets `Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, and a `Max-Age`. - Returning requests with consent and an existing `ts-ec` do not refresh the EC cookie or KV TTL. - Newly generated ECs receive `Set-Cookie: ts-ec=...`. -- When consent is blocked but not explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal. +- When the permission is not set but nothing was explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal. Withdrawal is deliberately narrow: a TCF record refusing storage in a jurisdiction whose baseline did not grant it. US-style opt-outs (GPC, a GPP sale opt-out, or a US Privacy opt-out) suppress use for the request but never expire the cookie or write a tombstone, so lifting the opt-out restores the identity. - `/_ts/api/v1/identify` is read-oriented and returns identity enrichment for the authenticated partner. It computes `cluster_size` only when the EC entry does not already store one. - `/_ts/api/v1/batch-sync` writes mappings into the EC identity graph. Mapping timestamps are retained for API compatibility but no longer order writes; valid mappings use idempotent last-write-wins semantics. - Pull sync fills missing partner UIDs only. Existing partner UIDs are not periodically refreshed because EC entries no longer store per-partner sync timestamps. diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index 3b3fe65ed..8745d9044 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -75,7 +75,7 @@ TOML file. - `publisher.domain` - `publisher.origin_url` - `publisher.proxy_secret` -- `ec.passphrase` +- `ec.hmac.passphrase` (when `ec.provider = "hmac"`) --- @@ -124,7 +124,6 @@ map keys preserve hyphens, so shell users must invoke the CLI through `env`: ```bash env 'TRUSTED_SERVER__PUBLISHER__DOMAIN=example.com' \ 'TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__TIMEOUT_MS=1000' \ - 'TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true' \ ts config validate ``` @@ -149,10 +148,13 @@ Failed to generate EC ID: HMAC error **Solution:** -1. Ensure `passphrase` names a secret-store entry in `trusted-server.toml`: +1. Ensure the `hmac` provider is selected and its `passphrase` names a secret-store entry in `trusted-server.toml`: ```toml [ec] +provider = "hmac" + +[ec.hmac] passphrase = "ec_passphrase" ``` @@ -160,7 +162,8 @@ passphrase = "ec_passphrase" passphrase value: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase +TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase ``` 3. Provision a high-entropy value of at least 32 characters under @@ -234,7 +237,7 @@ Upstream request timeout after 1000ms timeout_ms = 2000 ``` -Browser `[integrations.prebid].timeout_ms` is independent and does not control +Browser `[integration.prebid].timeout_ms` is independent and does not control Prebid Server transport. 2. Verify upstream service is responsive: @@ -296,7 +299,7 @@ Prebid Server returned 400: Invalid OpenRTB request profile_config = { debug = true } ``` -`[integrations.prebid].debug` controls browser Prebid.js only. +`[integration.prebid].debug` controls browser Prebid.js only. 2. Check logs for request/response details 3. Verify bidders are supported by your Prebid Server @@ -331,8 +334,10 @@ Next.js links still pointing to origin domain 2. Update `rewrite_attributes` to match actual keys: ```toml -[integrations.nextjs] -enabled = true +[integration] +provider = ["nextjs"] + +[integration.nextjs] rewrite_attributes = ["href", "link", "url", "src"] # Add keys you find ``` @@ -360,7 +365,10 @@ Failed to fetch Permutive SDK: 404 Not Found 2. Update configuration: ```toml -[integrations.permutive] +[integration] +provider = ["permutive"] + +[integration.permutive] organization_id = "myorg" workspace_id = "workspace-123" ``` @@ -381,15 +389,15 @@ curl https://myorg.edge.permutive.app/workspace-123-web.js No route matched for /integrations/custom/endpoint ``` -**Cause:** Integration not enabled or route not registered +**Cause:** `[integration] provider` does not name the integration, or the route is not registered **Solution:** -1. Enable integration: +1. Name the integration so it runs: ```toml -[integrations.custom] -enabled = true +[integration] +provider = ["custom"] ``` 2. Verify integration is compiled in (check build logs) @@ -642,10 +650,13 @@ cargo install viceroy --version 0.17.0 --locked --force ### Enable Debug Logging -Browser Prebid.js debug remains under `[integrations.prebid]`: +Browser Prebid.js debug remains under `[integration.prebid]`: ```toml -[integrations.prebid] +[integration] +provider = ["prebid"] + +[integration.prebid] debug = true ``` diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index bc1174033..e9513f137 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -327,8 +327,11 @@ Configure the secret-store key name in `trusted-server.toml`: ```toml [ec] -passphrase = "ec_passphrase" +provider = "hmac" ec_store = "ec_identity_store" + +[ec.hmac] +passphrase = "ec_passphrase" ``` Store the high-entropy passphrase under that key in `ts_secrets`. The resolved diff --git a/docs/guide/gdpr-compliance.md b/docs/guide/gdpr-compliance.md index 556a7abfd..235825265 100644 --- a/docs/guide/gdpr-compliance.md +++ b/docs/guide/gdpr-compliance.md @@ -7,9 +7,11 @@ Consent signal handling in Trusted Server. Trusted Server reads consent signals from each request, decodes them, and applies built-in enforcement rules to consent-gated activities such as EC creation and EID forwarding. The publisher configures how -signals are interpreted: which countries and US states map to each -jurisdiction's rules, how Global Privacy Control is read, how -conflicting signals are resolved, and when stored signals expire. +signals are interpreted, meaning how Global Privacy Control is read, +how conflicting signals are resolved, and when stored signals expire. +Which countries and US states fall under which jurisdiction's rules is +not set here, because the `permissions.yaml` rules tree states it. See +the [Permission Model](/guide/permission-model). The per-activity gates and their fail-closed defaults are built in. ## Policy Posture @@ -96,12 +98,6 @@ Configure consent handling in the `[consent]` section of mode = "interpreter" # or "proxy" (forward raw strings without decoding) max_consent_age_days = 365 # expiration check for dated signals -[consent.gdpr] -applies_in = ["DE", "FR"] # countries mapped to the GDPR rules - -[consent.us_states] -privacy_states = ["CA", "CO"] # US states mapped to the US state rules - [consent.us_privacy_defaults] gpc_implies_optout = true # how the Sec-GPC header is interpreted @@ -112,6 +108,17 @@ mode = "restrictive" # or "newest" / "permissive" Each field tunes how signals are interpreted. The per-jurisdiction gates and their fail-closed defaults are built in. +Which jurisdiction applies to a visitor is not configured here. The +`[consent.gdpr] applies_in` and `[consent.us_states] privacy_states` +lists are retired, and a `jurisdiction` attribute on the +`permissions.yaml` rules tree does their job. Every node of that tree +may name the jurisdiction for the places it covers, a node that names +none inherits the nearest one above it, and the top of the tree answers +a visitor whose country cannot be resolved. One file therefore carries +the permission baselines and the jurisdiction assignment together. The +[Permission Model](/guide/permission-model) documents the tree, so this +page does not repeat it. + ## Operational Behavior - Consent checks run before consent-gated activities (EC creation, @@ -120,7 +127,8 @@ gates and their fail-closed defaults are built in. Resolution of conflicting signals is configurable (restrictive, newest, or permissive). - Audit logging records the consent decision per gated activity. -- Regional rules are applied per detected jurisdiction. +- Regional rules are applied per detected jurisdiction, which the rules + tree assigns from the visitor's country and region. ## Best Practices @@ -136,6 +144,7 @@ gates and their fail-closed defaults are built in. ## Next Steps +- [Permission Model](/guide/permission-model) - [Configuration Reference](/guide/configuration) - [Edge Cookies](/guide/edge-cookies) - [Architecture](/guide/architecture) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index fb7d1c84c..f0bc9db9a 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -157,18 +157,19 @@ only the asset proxy entries you want to serve or block. Then validate it. Edit `trusted-server.toml` to configure: -- browser integrations under `[integrations.*]`; -- server auction providers under map-shaped `[auction.providers.]`; -- server bidder routes under `[auction.bidders.]`; -- KV store mappings; -- EC configuration; -- consent settings (`[gdpr]`); and -- stable key names for `trusted_server_secrets`. +- the integrations that run, in `[integration] provider`, with their settings under `[integration.]` +- the demand sources, in `[demand] provider`, each with its settings under `[demand.]` +- the ad server, if one runs, in `[adserver] provider`, with its settings under `[adserver.]` +- server bidder routes under `[auction.bidders.]` +- KV store mappings +- Edge Cookie configuration under `[ec]` +- stable key names for `trusted_server_secrets` Do not put a Prebid Server URL or server bidder list under -`[integrations.prebid]`, and do not put APS account/endpoint/timeout fields under -`[integrations.aps]`. Those server values belong to auction provider common -fields and `profile_config`. +`[integration.prebid]`. Those server values belong to a `[demand.]` table +whose `implementation` is `prebid_server`, and APS has no integration table at +all, because it is selected in `[demand]` too. The rules every one of these +tables follows are in [Configuration Rules](/guide/configuration-rules). Before the first push, provision the physical store mapped from logical `trusted_server_secrets` with the credential values referenced by the config. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index f127d4207..626d52175 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -130,10 +130,12 @@ impl PlatformBackend for FixtureBackend { struct FixtureGeo; +#[async_trait::async_trait(?Send)] impl PlatformGeo for FixtureGeo { - fn lookup( + async fn lookup( &self, _client_ip: Option, + _services: &RuntimeServices, ) -> Result, Report> { Ok(None) } diff --git a/docs/guide/integrations/adserver_mock.md b/docs/guide/integrations/adserver_mock.md index cc3fa4444..d23b9a563 100644 --- a/docs/guide/integrations/adserver_mock.md +++ b/docs/guide/integrations/adserver_mock.md @@ -4,7 +4,8 @@ is not an ordinary bidder provider and does not expose an integration proxy route. -Enable `[integrations.adserver_mock]` and set +Name `adserver_mock` in `[integration] provider`, give it an +`[integration.adserver_mock]` table, and set `auction.mediator = "adserver_mock"`. Startup registers the mediator only when both conditions hold. The orchestrator first gathers responses from the configured auction providers, then passes successful bids to the mediator's diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 5e1b90e6c..ce4302600 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -1,6 +1,6 @@ # Amazon Publisher Services (APS) OpenRTB Integration -Trusted Server can request banner bids from Amazon Publisher Services (APS) through the APS OpenRTB endpoint and let their decoded USD CPMs compete with other auction providers. +Trusted Server can request banner bids from Amazon Publisher Services (APS) through the APS OpenRTB endpoint and let their decoded USD CPMs compete with the other demand sources in the auction. > [!IMPORTANT] > APS's public adapter metadata describes Prebid Server support as unavailable. Confirm edge/server-originated traffic with your APS account team before a broad production rollout. Start with an isolated cohort and disable publisher-native APS demand for that cohort to avoid duplicate demand. @@ -11,7 +11,7 @@ The integration supports: - banner impressions; - APS OpenRTB requests to the provider's configured HTTPS endpoint; -- decoded-CPM winner selection with or without a mediator; +- decoded-CPM winner selection with or without an ad server - direct `/auction` rendering; - client-side `trustedServer` Prebid adapter auctions through GAM; and - initial-navigation and page-bids rendering through GAM/Prebid Universal Creative. @@ -25,49 +25,40 @@ The integration does not implement: ## Configuration -APS server ownership is entirely under an auction provider. The optional -`[integrations.aps]` table controls browser-side behavior; it does not own the -APS account, endpoint, timeout, debug behavior, inventory identity, or script -policy. APS renderer support is registered whenever the compiled auction plan -contains an `aps` profile, even if `[integrations.aps]` is absent or disabled. +APS is a demand implementation, not a page integration, so it is never named in +`[integration] provider`. Everything APS owns, including rendering ownership, +lives in the `[demand.]` table that names `implementation = "aps"`. APS +renderer support is registered whenever the compiled auction plan contains an +APS demand source. See [Configuration Rules](/guide/configuration-rules) for +the syntax every provider type shares. ```toml [auction] enabled = true timeout_ms = 2000 -mediator = "adserver_mock" +[demand] +provider = ["aps_main"] -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" - -[auction.providers.aps-main.profile_config] account_id = "example-aps-account" debug = false allow_script_creatives = false +# Default. Set publisher_native only for the controlled friendly-frame experiment below. +rendering_mode = "trusted_server" # Configure both only when authorized inventory differs from the deployment host. # inventory_domain = "inventory.example.com" # inventory_page_origin = "https://www.inventory.example.com" -[integrations.adserver_mock] -enabled = true -endpoint = "https://mediator.example.com/mediate" -timeout_ms = 500 -``` - -The optional browser integration table controls rendering ownership. An absent -or `enabled = false` block keeps the default trusted-server rendering. The -renderer route stays registered while an APS provider is in the auction plan, -and a `rendering_mode` inside a disabled block is ignored: +[adserver] +provider = "adserver_mock" -```toml -[integrations.aps] -enabled = true -# Default. Set publisher_native only for the controlled friendly-frame experiment below. -rendering_mode = "trusted_server" +[adserver.adserver_mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 ``` `rendering_mode` is a strict enum. `trusted_server` (the default) retains the @@ -129,19 +120,18 @@ this server-selected bid can duplicate demand. Validate the exact account, inventory, CSP, iframe/script creative behavior, impression reporting, and click-through behavior with the APS account team before production rollout. -The common provider `endpoint` is required and must be an absolute HTTPS URL -with a host and no credentials or fragment. The legacy `/e/dtb/bid` path is -rejected. `timeout_ms` belongs beside `endpoint`; when omitted, the `aps` -profile default is 800 ms. Runtime caps it by the remaining auction budget. +`endpoint` is required and must be an absolute HTTPS URL with a host and no +credentials or fragment. The legacy `/e/dtb/bid` path is rejected. `timeout_ms` +sits beside it, and when omitted the `aps` implementation default of 800 ms +applies. Runtime caps it by the remaining auction budget. -`profile_config.account_id` is required, nonempty, and at most 1024 bytes. It is -the canonical field; integration-owned `account_id`, `pub_id`, endpoint, and -timeout fields are not part of the public schema. `debug` and +`account_id` is required, nonempty, and at most 1024 bytes. It is the canonical +field, and `pub_id` is not part of the public schema. `debug` and `allow_script_creatives` both default to `false`. Enable `debug` only on controlled test sites because it includes the raw APS -request and response—including identity, consent, device, page, account, bid, -and creative data—in client-visible `/auction` metadata. +request and response, including identity, consent, device, page, account, bid, +and creative data, in client-visible `/auction` metadata. Set `inventory_domain` and `inventory_page_origin` together only when the public deployment hostname differs from APS-authorized inventory. The domain becomes @@ -152,26 +142,26 @@ path, query, or fragment. `routing = "all_eligible"` is the usual APS configuration: every banner-compatible slot is eligible without a synthetic APS bidder entry. It -does not expose bidder parameters routed to another provider. Use +does not expose bidder parameters routed to another demand source. Use `routing = "explicit"` only when APS participation should require a central bidder route: ```toml -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand] +provider = ["aps_main"] + +[demand.aps_main] +implementation = "aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "explicit" - -[auction.providers.aps-main.profile_config] account_id = "example-aps-account" [auction.bidders.aps] -provider = "aps-main" +provider = "aps_main" ``` -The optional mediator stays separate under `[auction].mediator`; never declare -it under `[auction.providers]` or `[auction.bidders]`. +The optional ad server stays separate under `[adserver] provider`. Never name +it in `[demand] provider` or `[auction.bidders]`. APS uses ordinary auction slot IDs and banner formats. Legacy creative- opportunity APS `slot_id` values are ignored, and `bidders.aps.slotID` is not @@ -193,9 +183,8 @@ Raw outbound and inbound payloads are logged only at TRACE level. With debug dis ## Debug mode -Set `debug = true` under -`[auction.providers..profile_config]` to include the direct APS HTTP -exchange in that provider's summary returned by `POST /auction`: +Set `debug = true` in the APS `[demand.]` table to include the direct APS +HTTP exchange in that source's summary returned by `POST /auction`: ```json { @@ -321,10 +310,13 @@ If script rendering requires weakening the outer sandbox, leave `allow_script_cr This release is a direct configuration and protocol cutover: -1. Move `endpoint` and `timeout_ms` to `[auction.providers.]` and use - `/e/pb/bid`; `/e/dtb/bid` remains rejected. -2. Move `account_id`, `debug`, `allow_script_creatives`, and inventory overrides - to the provider's `profile_config`; `pub_id` is not part of the new schema. +1. Move `endpoint` and `timeout_ms` to a `[demand.]` table that sets + `implementation = "aps"`, and use `/e/pb/bid`. `/e/dtb/bid` remains + rejected. +2. Move `account_id`, `debug`, `allow_script_creatives`, `rendering_mode` and + the inventory overrides into that same table, flat beside `endpoint`. + `pub_id` is not part of the new schema, and the old `[integrations.aps]` + block is gone. 3. Remove APS-specific slot ID configuration and any APS entry from old Prebid Server bidder lists. Use `routing = "all_eligible"` or an explicit `[auction.bidders.aps]` route. @@ -333,7 +325,7 @@ This release is a direct configuration and protocol cutover: 5. Disable publisher-native APS demand for the Trusted Server test cohort. There is no legacy runtime switch. To roll back traffic, disable `[auction]` or -remove the APS provider and restore native APS for the cohort. To roll back the +remove the APS demand source and restore native APS for the cohort. To roll back the binary, restore the old-schema configuration blob with the old binary. A prior binary rejects the new `[auction.bidders]` field even when auction execution is disabled. @@ -360,10 +352,11 @@ Use fictional values in source-controlled configuration and fixtures. Supply con - Confirm `account_id` and account eligibility with APS. - Confirm the endpoint is `/e/pb/bid` and uses HTTPS without credentials. - If the deployment hostname differs from APS-authorized inventory, configure both `inventory_domain` and `inventory_page_origin` with the APS-approved identity. -- Ensure an `[auction.providers.]` entry selects `profile = "aps"`. +- Ensure a `[demand.]` table sets `implementation = "aps"` and that + `[demand] provider` names it. - Check aggregate APS drop reasons for currency, dimensions, render source, URL, tag type, or script-gate rejection. -- Confirm the provider timeout fits inside the auction timeout. -- On a controlled test site, set profile `debug = true` and inspect +- Confirm the demand source timeout fits inside the auction timeout. +- On a controlled test site, set `debug = true` in that table and inspect `ext.orchestrator.provider_details[].metadata.debug.httpcalls.aps` in the `/auction` response. diff --git a/docs/guide/integrations/datadome.md b/docs/guide/integrations/datadome.md index f289a77b9..16112f136 100644 --- a/docs/guide/integrations/datadome.md +++ b/docs/guide/integrations/datadome.md @@ -32,8 +32,10 @@ The DataDome integration can: Add the following to your `trusted-server.toml`: ```toml -[integrations.datadome] -enabled = true +[integration] +provider = ["datadome"] + +[integration.datadome] # First-party JavaScript/proxy layer sdk_origin = "https://js.datadome.co" @@ -60,7 +62,7 @@ inject_client_side_tag = true client_side_tag_url = "/integrations/datadome/tags.js" client_side_configuration = { ajaxListenerPath = true } -[[integrations.datadome.protection_exclusion_rules]] +[[integration.datadome.protection_exclusion_rules]] id = "default-static-assets" type = "path_regex" patterns = ["(?i)\\.(avi|flv|mka|mkv|mov|mp4|mpeg|mpg|mp3|flac|ogg|ogm|opus|wav|webm|webp|bmp|gif|ico|jpeg|jpg|png|svg|svgz|swf|eot|otf|ttf|woff|woff2|css|less|js|map)$"] @@ -70,7 +72,6 @@ patterns = ["(?i)\\.(avi|flv|mka|mkv|mov|mp4|mpeg|mpg|mp3|flac|ogg|ogm|opus|wav| | Option | Type | Default | Description | | -------------------------------------- | ------- | -------------------------------- | ----------------------------------------------------------------------- | -| `enabled` | boolean | `false` | Enable the DataDome integration | | `sdk_origin` | string | `https://js.datadome.co` | DataDome SDK origin URL for `tags.js` | | `api_origin` | string | `https://api-js.datadome.co` | DataDome signal collection API origin URL for `/js/*` | | `cache_ttl_seconds` | integer | `3600` | Cache TTL for `tags.js` | @@ -99,8 +100,10 @@ patterns = ["(?i)\\.(avi|flv|mka|mkv|mov|mp4|mpeg|mpg|mp3|flac|ogg|ogm|opus|wav| Set `client_side_key` to have Trusted Server inject the DataDome browser tag into processed HTML responses: ```toml -[integrations.datadome] -enabled = true +[integration] +provider = ["datadome"] + +[integration.datadome] client_side_key = "YOUR_DATADOME_JS_KEY" inject_client_side_tag = true ``` @@ -118,7 +121,10 @@ Trusted Server emits the DataDome configuration before the Trusted Server JavaSc If your site already manages the DataDome tag, disable auto-injection: ```toml -[integrations.datadome] +[integration] +provider = ["datadome"] + +[integration.datadome] inject_client_side_tag = false ``` @@ -161,7 +167,7 @@ When `enable_protection = true`, Trusted Server calls DataDome before normal rou A request is protected when all of the following are true: -1. The DataDome integration is enabled. +1. `[integration] provider` names the DataDome integration. 2. `enable_protection = true`. 3. The method is not listed in `protection_excluded_methods`. 4. The path is not one of Trusted Server's internal routes. @@ -182,7 +188,10 @@ Protection API: ```toml # Runtime activation also requires FASTLY_IS_STAGING=1. -[integrations.datadome.protection_test_bypass] +[integration] +provider = ["datadome"] + +[integration.datadome.protection_test_bypass] enabled = true credential_secret_name = "datadome_test_bypass" ``` @@ -262,7 +271,10 @@ fields. For example: Use structured rules for all DataDome protection exclusions. Each rule has an `id`, optional `methods`, and a typed matcher. The default configuration includes a `path_regex` rule for common static assets. ```toml -[[integrations.datadome.protection_exclusion_rules]] +[integration] +provider = ["datadome"] + +[[integration.datadome.protection_exclusion_rules]] id = "legacy-static-get-head" methods = ["GET", "HEAD"] type = "path_regex" @@ -272,7 +284,7 @@ patterns = [ "^/robots\\.txt$", ] -[[integrations.datadome.protection_exclusion_rules]] +[[integration.datadome.protection_exclusion_rules]] id = "next-rsc" methods = ["GET", "HEAD"] type = "query_param_non_empty" @@ -292,7 +304,10 @@ Supported rule types are: Config Store-backed CIDR sources accept newline-, comma-, whitespace-, or JSON-array encoded CIDR lists. They are useful for large or frequently updated vendor crawler lists. ```toml -[[integrations.datadome.protection_excluded_ip_cidr_sources]] +[integration] +provider = ["datadome"] + +[[integration.datadome.protection_excluded_ip_cidr_sources]] config_store = "datadome-ip-bypass" key = "googlebot_ips" ``` @@ -367,14 +382,13 @@ sequenceDiagram Override configuration via environment variables: ```bash -TRUSTED_SERVER__INTEGRATIONS__DATADOME__ENABLED=true -TRUSTED_SERVER__INTEGRATIONS__DATADOME__SDK_ORIGIN=https://js.datadome.co -TRUSTED_SERVER__INTEGRATIONS__DATADOME__API_ORIGIN=https://api-js.datadome.co -TRUSTED_SERVER__INTEGRATIONS__DATADOME__CACHE_TTL_SECONDS=3600 -TRUSTED_SERVER__INTEGRATIONS__DATADOME__REWRITE_SDK=true -TRUSTED_SERVER__INTEGRATIONS__DATADOME__ENABLE_PROTECTION=true -TRUSTED_SERVER__INTEGRATIONS__DATADOME__SERVER_SIDE_KEY_SECRET_NAME=datadome_server_side_key -TRUSTED_SERVER__INTEGRATIONS__DATADOME__CLIENT_SIDE_KEY=your-client-side-key +TRUSTED_SERVER__INTEGRATION__DATADOME__SDK_ORIGIN=https://js.datadome.co +TRUSTED_SERVER__INTEGRATION__DATADOME__API_ORIGIN=https://api-js.datadome.co +TRUSTED_SERVER__INTEGRATION__DATADOME__CACHE_TTL_SECONDS=3600 +TRUSTED_SERVER__INTEGRATION__DATADOME__REWRITE_SDK=true +TRUSTED_SERVER__INTEGRATION__DATADOME__ENABLE_PROTECTION=true +TRUSTED_SERVER__INTEGRATION__DATADOME__SERVER_SIDE_KEY_SECRET_NAME=datadome_server_side_key +TRUSTED_SERVER__INTEGRATION__DATADOME__CLIENT_SIDE_KEY=your-client-side-key ``` ## Client-side script guard @@ -393,11 +407,11 @@ This keeps DataDome scripts routed through first-party context, even when insert ### Script not loading -Check that the integration is enabled: +Check that `[integration] provider` names the integration: ```toml -[integrations.datadome] -enabled = true +[integration] +provider = ["datadome"] ``` If you rely on auto-injection, verify `client_side_key` is non-empty and `inject_client_side_tag = true`. @@ -415,8 +429,10 @@ curl -X POST https://www.example.com/integrations/datadome/js/check Check that both fields are configured: ```toml -[integrations.datadome] -enabled = true +[integration] +provider = ["datadome"] + +[integration.datadome] enable_protection = true server_side_key_secret_name = "datadome_server_side_key" ``` diff --git a/docs/guide/integrations/didomi.md b/docs/guide/integrations/didomi.md index a2a64e434..f58fe2cf7 100644 --- a/docs/guide/integrations/didomi.md +++ b/docs/guide/integrations/didomi.md @@ -26,8 +26,10 @@ for the upstream contract. Add the integration to the operator-owned `trusted-server.toml`: ```toml -[integrations.didomi] -enabled = true +[integration] +provider = ["didomi"] + +[integration.didomi] geo_query_parameters = true # proxy_path = "my-custom-consent" # sdk_origin = "https://sdk.privacy-center.org" @@ -40,13 +42,12 @@ Publish application configuration with: ts config push --adapter fastly ``` -| Field | Type | Required | Default | Description | -| ---------------------- | ------- | -------- | ------------------------------------- | ------------------------------------------------------- | -| `enabled` | boolean | No | `true` in a present integration block | Enables the integration | -| `geo_query_parameters` | boolean | No | `false` | Enables trusted geo canonicalization for notice loaders | -| `proxy_path` | string | No | `integrations/didomi/consent` | Changes the first-party path prefix | -| `sdk_origin` | string | No | `https://sdk.privacy-center.org` | Changes the SDK origin, primarily for testing | -| `api_origin` | string | No | `https://api.privacy-center.org` | Changes the API origin, primarily for testing | +| Field | Type | Required | Default | Description | +| ---------------------- | ------- | -------- | -------------------------------- | ------------------------------------------------------- | +| `geo_query_parameters` | boolean | No | `false` | Enables trusted geo canonicalization for notice loaders | +| `proxy_path` | string | No | `integrations/didomi/consent` | Changes the first-party path prefix | +| `sdk_origin` | string | No | `https://sdk.privacy-center.org` | Changes the SDK origin, primarily for testing | +| `api_origin` | string | No | `https://api.privacy-center.org` | Changes the API origin, primarily for testing | `geo_query_parameters` is disabled by default for compatibility. It currently supports Fastly only because Cloudflare does not expose a trusted region through @@ -62,8 +63,10 @@ already exists in the TOML input. `proxy_path` helps avoid a predictable integration path: ```toml -[integrations.didomi] -enabled = true +[integration] +provider = ["didomi"] + +[integration.didomi] geo_query_parameters = true proxy_path = "my-custom-consent" ``` diff --git a/docs/guide/integrations/gam.md b/docs/guide/integrations/gam.md index 304619ede..a565cdcbc 100644 --- a/docs/guide/integrations/gam.md +++ b/docs/guide/integrations/gam.md @@ -1,7 +1,7 @@ # Google Ad Manager integration status Trusted Server does not ship a direct Google Ad Manager integration or an -`[integrations.gam]` configuration section. +`[integration.gam]` configuration section. The TSJS GPT module can place server-side auction results into publisher-owned GPT slots, including APS renderer winners. That browser handoff is not a GAM diff --git a/docs/guide/integrations/google_tag_manager.md b/docs/guide/integrations/google_tag_manager.md index c60efac22..c66b4f9c3 100644 --- a/docs/guide/integrations/google_tag_manager.md +++ b/docs/guide/integrations/google_tag_manager.md @@ -34,8 +34,10 @@ This is a deliberate design choice to limit data forwarded to Google. If your us Add the GTM configuration to `trusted-server.toml`: ```toml -[integrations.google_tag_manager] -enabled = true +[integration] +provider = ["google_tag_manager"] + +[integration.google_tag_manager] container_id = "GTM-XXXXXX" # upstream_url = "https://www.googletagmanager.com" # Optional override (must be https) # allowed_tag_ids = ["G-XXXXXXXX"] # Tag ids besides container_id that gtag/js may serve first-party @@ -45,14 +47,13 @@ container_id = "GTM-XXXXXX" ### Configuration Options -| Field | Type | Required | Description | -| ---------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | boolean | No | Enable/disable integration (default: `false`) | -| `container_id` | string | Yes | Your GTM Container ID (e.g., `GTM-A1B2C3`) | -| `upstream_url` | string | No | Custom upstream, as a credential-free `https` bare origin with a literal host — no path, query, fragment, userinfo or wildcard (default: `https://www.googletagmanager.com`) | -| `allowed_tag_ids` | array | No | Extra tag ids servable on `gtag/js` besides `container_id` (default: none) | -| `cache_max_age` | number | No | Cache duration in seconds (default: `900`, range: `60`-`86400`) | -| `max_beacon_body_size` | number | No | Max POST body size in bytes (default: `65536`, range: `1024`-`1048576`) | +| Field | Type | Required | Description | +| ---------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `container_id` | string | Yes | Your GTM Container ID (e.g., `GTM-A1B2C3`) | +| `upstream_url` | string | No | Custom upstream, as a credential-free `https` bare origin with a literal host, with no path, query, fragment, userinfo or wildcard (default: `https://www.googletagmanager.com`) | +| `allowed_tag_ids` | array | No | Extra tag ids servable on `gtag/js` besides `container_id` (default: none) | +| `cache_max_age` | number | No | Cache duration in seconds (default: `900`, range: `60`-`86400`) | +| `max_beacon_body_size` | number | No | Max POST body size in bytes (default: `65536`, range: `1024`-`1048576`) | ### Upgrading: check `allowed_tag_ids` @@ -101,7 +102,7 @@ A trailing slash is still accepted; it is trimmed before targets are built. **This is a breaking change.** A path-based value such as `https://tags.example.com/gateway` passed validation before this release and -produced targets below that path, so an enabled deployment using one now fails +produced targets below that path, so a deployment using one now fails config validation. `ts config push` rejects it, which is where you should expect to see this. Validation is fail-closed rather than skip-the-integration: a config that reaches the runtime without having been pushed through that check diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index e419a1a79..09412501a 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -23,17 +23,17 @@ server integration configuration plus `?ts_console=1`; it does not require new publisher JavaScript, React, Next.js, DOM, or GAM configuration. The diagnostics integration is independent of the -[GPT first-party script integration](./gpt.md). Either integration can be enabled +[GPT first-party script integration](./gpt.md). Either integration can run without the other, although Trusted Server creative-progress evidence is available only for slots served through the existing GPT integration. ## Deployment Configuration -The module is unavailable unless explicitly enabled for the deployment: +The module is unavailable unless the deployment names it: ```toml -[integrations.gpt_diagnostics] -enabled = true +[integration] +provider = ["gpt_diagnostics"] ``` Deployment configuration makes the module available; it does not activate any browser @@ -477,7 +477,7 @@ trigger GPT or Prebid work, gate an auction, or delay delivery. ### The API or panel is absent -1. Confirm `[integrations.gpt_diagnostics]` is enabled in the deployed configuration. +1. Confirm `[integration] provider` names `gpt_diagnostics` in the deployed configuration. 2. Activate the browser session with an exact recognized `ts_console` value. 3. Confirm the Trusted Server script bundle loaded successfully. 4. Use `ts_console=false` and then `ts_console=true` on a new document to reset diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md index ae070950f..cc0b66919 100644 --- a/docs/guide/integrations/gpt.md +++ b/docs/guide/integrations/gpt.md @@ -51,8 +51,10 @@ There are three layers: Add GPT configuration to `trusted-server.toml`: ```toml -[integrations.gpt] -enabled = true +[integration] +provider = ["gpt"] + +[integration.gpt] gam_attribution_enabled = false script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" cache_ttl_seconds = 3600 @@ -63,15 +65,14 @@ rewrite_script = true | Field | Type | Required | Default | Description | | ------------------------- | ------- | -------- | ------------------------------------------------------ | ----------------------------------------------------------------- | -| `enabled` | boolean | No | `true` | Enable/disable the integration | | `gam_attribution_enabled` | boolean | No | `false` | Add fixed page-level `ts=true` targeting for GAM cohort reporting | | `script_url` | string | No | `https://securepubads.g.doubleclick.net/tag/js/gpt.js` | URL for the GPT bootstrap script | | `cache_ttl_seconds` | integer | No | `3600` | Cache TTL for proxied scripts (60--86400s) | | `rewrite_script` | boolean | No | `true` | Whether to rewrite GPT script URLs in HTML | The environment override -`TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED` works only when -`gam_attribution_enabled` is already present under `[integrations.gpt]` in the +`TRUSTED_SERVER__INTEGRATION__GPT__GAM_ATTRIBUTION_ENABLED` works only when +`gam_attribution_enabled` is already present under `[integration.gpt]` in the TOML file. The environment overlay cannot create a missing configuration leaf. ## Endpoints @@ -155,7 +156,7 @@ value `ts=true`. It is applied before publisher GPT initialization and remains for the browser document's lifetime, so initial, lazy, refresh, publisher-owned, and SPA-route requests inherit it unless another targeting consumer clears or overrides the key. The attribution switch is independently controlled and -defaults to `false`, but the GPT integration's `enabled` master switch must also +defaults to `false`, but `[integration] provider` must also name `gpt`, which be `true`. This key is distinct from the existing slot-level `ts_initial=1` value. diff --git a/docs/guide/integrations/kargo.md b/docs/guide/integrations/kargo.md index 4e1c71a95..d52f5de36 100644 --- a/docs/guide/integrations/kargo.md +++ b/docs/guide/integrations/kargo.md @@ -1,7 +1,7 @@ # Kargo integration status Trusted Server does not ship a native Kargo integration or an -`[integrations.kargo]` configuration section. +`[integration.kargo]` configuration section. Kargo can be used only as upstream demand through a separately configured OpenRTB or Prebid Server path when the publisher and upstream service support diff --git a/docs/guide/integrations/lockr.md b/docs/guide/integrations/lockr.md index ef9e57b0f..874cc8929 100644 --- a/docs/guide/integrations/lockr.md +++ b/docs/guide/integrations/lockr.md @@ -41,8 +41,10 @@ Lockr is an identity resolution and privacy platform that helps publishers manag Add Lockr configuration to `trusted-server.toml`: ```toml -[integrations.lockr] -enabled = true +[integration] +provider = ["lockr"] + +[integration.lockr] api_endpoint = "https://api.lockr.io" organization_id = "your-org-id" project_id = "your-project-id" @@ -50,20 +52,18 @@ project_id = "your-project-id" ### Configuration Options -| Field | Type | Required | Description | -| ----------------- | ------- | -------- | --------------------------------------------- | -| `enabled` | boolean | No | Enable/disable integration (default: `false`) | -| `api_endpoint` | string | Yes | Lockr API endpoint URL | -| `organization_id` | string | Yes | Your Lockr organization ID | -| `project_id` | string | Yes | Your Lockr project ID | +| Field | Type | Required | Description | +| ----------------- | ------ | -------- | -------------------------- | +| `api_endpoint` | string | Yes | Lockr API endpoint URL | +| `organization_id` | string | Yes | Your Lockr organization ID | +| `project_id` | string | Yes | Your Lockr project ID | ### Environment Variables ```bash -TRUSTED_SERVER__INTEGRATIONS__LOCKR__ENABLED=true -TRUSTED_SERVER__INTEGRATIONS__LOCKR__API_ENDPOINT=https://api.lockr.io -TRUSTED_SERVER__INTEGRATIONS__LOCKR__ORGANIZATION_ID=your-org-id -TRUSTED_SERVER__INTEGRATIONS__LOCKR__PROJECT_ID=your-project-id +TRUSTED_SERVER__INTEGRATION__LOCKR__API_ENDPOINT=https://api.lockr.io +TRUSTED_SERVER__INTEGRATION__LOCKR__ORGANIZATION_ID=your-org-id +TRUSTED_SERVER__INTEGRATION__LOCKR__PROJECT_ID=your-project-id ``` ## Features diff --git a/docs/guide/integrations/nextjs.md b/docs/guide/integrations/nextjs.md index 7c6cc7c50..38cb6f1a1 100644 --- a/docs/guide/integrations/nextjs.md +++ b/docs/guide/integrations/nextjs.md @@ -26,8 +26,10 @@ Next.js applications generate framework-specific JSON data (`__NEXT_DATA__`) and ## Configuration ```toml -[integrations.nextjs] -enabled = false +[integration] +provider = ["nextjs"] + +[integration.nextjs] rewrite_attributes = ["href", "link", "url"] max_combined_payload_bytes = 10485760 ``` @@ -36,7 +38,6 @@ max_combined_payload_bytes = 10485760 | Field | Type | Default | Description | | ---------------------------- | ------- | ------------------------- | ----------------------------------------------- | -| `enabled` | boolean | `false` | Enable Next.js integration | | `rewrite_attributes` | array | `["href", "link", "url"]` | Attributes to rewrite in Next.js data | | `max_combined_payload_bytes` | integer | `10485760` | Maximum bytes retained for one unresolved group | @@ -141,11 +142,11 @@ Combine Next.js SSR/SSG with Trusted Server edge logic. ### 1. Enable Only When Needed -Only enable if you're using Next.js: +Name it only if you're using Next.js: ```toml -[integrations.nextjs] -enabled = true # Only if using Next.js +[integration] +provider = ["nextjs"] ``` ### 2. Configure Rewrite Attributes @@ -153,7 +154,10 @@ enabled = true # Only if using Next.js Add custom attributes if your Next.js app uses non-standard fields: ```toml -[integrations.nextjs] +[integration] +provider = ["nextjs"] + +[integration.nextjs] rewrite_attributes = ["href", "link", "url", "customImageUrl"] ``` diff --git a/docs/guide/integrations/osano.md b/docs/guide/integrations/osano.md index 2676db928..cfc935e0f 100644 --- a/docs/guide/integrations/osano.md +++ b/docs/guide/integrations/osano.md @@ -29,8 +29,8 @@ It writes the corresponding first-party cookies when Osano reports ready consent Add the following to `trusted-server.toml`: ```toml -[integrations.osano] -enabled = true +[integration] +provider = ["osano"] ``` No additional server-side settings are required for the initial Osano integration. diff --git a/docs/guide/integrations/permutive.md b/docs/guide/integrations/permutive.md index e3c925842..e40e78bd5 100644 --- a/docs/guide/integrations/permutive.md +++ b/docs/guide/integrations/permutive.md @@ -15,8 +15,10 @@ Permutive is a real-time data platform that helps publishers build and activate ## Configuration ```toml -[integrations.permutive] -enabled = true +[integration] +provider = ["permutive"] + +[integration.permutive] organization_id = "your-org-id" workspace_id = "your-workspace-id" project_id = "your-project-id" diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 20d37f1cc..bc1bcb7de 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -16,15 +16,21 @@ Prebid is the leading open-source header bidding solution that allows publishers Prebid configuration has two independent owners: -- `[integrations.prebid]` owns browser Prebid.js behavior: bundle selection and - injection, browser timeout/debug, account injection, script interception, - client-side bidders, and refresh exclusions. -- `[auction.providers.]`, its `profile_config`, `notifications`, and - `[auction.bidders]` own every Prebid Server request. +- `[integration.prebid]` owns browser Prebid.js behavior, being bundle + selection and injection, browser timeout and debug, account injection, + script interception, client-side bidders, and refresh exclusions. It runs + when `prebid` is named in `[integration] provider`. +- A `[demand.]` table that sets `implementation = "prebid_server"`, its + `notifications`, and `[auction.bidders]` own every Prebid Server request. + Prebid Server is a demand implementation, not a page integration, so it is + never named in `[integration] provider`. See + [Configuration Rules](/guide/configuration-rules). ```toml -[integrations.prebid] -enabled = true +[integration] +provider = ["prebid"] + +[integration.prebid] timeout_ms = 1000 debug = false client_side_bidders = ["example-browser"] @@ -35,11 +41,11 @@ external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" # external_bundle_sri = "sha384-" # Optional operator-owned Prebid User ID modules, forwarded to Prebid verbatim. -[[integrations.prebid.managed_user_ids]] +[[integration.prebid.managed_user_ids]] name = "identityLink" params = { pid = "999", notUse3P = false } -[integrations.prebid.managed_user_ids.storage] +[integration.prebid.managed_user_ids.storage] type = "cookie" name = "idl_env" expires = 15 @@ -47,7 +53,7 @@ refresh_in_seconds = 1800 # External bundle generation inputs used by `ts prebid bundle`. # Values are exact Prebid module stems without `.js`. -[integrations.prebid.bundle.modules] +[integration.prebid.bundle.modules] bidder = ["rubiconBidAdapter"] user_id = ["sharedIdSystem", "identityLinkIdSystem"] analytics = ["atsAnalyticsAdapter"] @@ -59,14 +65,14 @@ allowed_domains = ["assets.example.com"] enabled = true timeout_ms = 2000 -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +provider = ["pbs_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" timeout_ms = 900 routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false test_mode = false debug_query_params = "example-debug=1" @@ -74,56 +80,57 @@ consent_forwarding = "both" bid_param_overrides = { example-server = { placement = "example-placement" } } bid_param_zone_overrides = { example-server = { header = { placement = "example-header" } } } -[[auction.providers.pbs-main.profile_config.bid_param_override_rules]] +[[demand.pbs_main.bid_param_override_rules]] when.bidder = "example-server" when.zone = "header" set = { placement = "example-rule-placement" } -[auction.providers.pbs-main.notifications] +[demand.pbs_main.notifications] suppress_all = false suppress_seats = ["example-seat"] [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" ``` ### Browser configuration options -| Field | Default | Ownership and behavior | -| ----------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `enabled` | `true` | Enables browser bundle injection/interception; it does not create a server provider | -| `account_id` | `None` | Optional browser-injected account value | -| `timeout_ms` | `1000` | Browser Prebid.js timeout only | -| `debug` | `false` | Browser Prebid.js debug only | -| `client_side_bidders` | `[]` | Native browser adapters that are not folded into `trustedServer` | -| `excluded_gam_ad_unit_path_suffixes` | `[]` | GAM suffixes omitted from Trusted Server refresh auctions | -| `script_patterns` | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | Publisher Prebid scripts intercepted to prevent duplicate instances | -| `external_bundle_url` | Required when enabled | HTTPS generated bundle URL; host and redirects must be in `proxy.allowed_domains` | -| `external_bundle_sha256` | `None` | Optional content hash used for versioning, cache policy, and ETag | -| `external_bundle_sri` | `None` | Optional SRI metadata | -| `bundle.modules.bidder` | Required and non-empty | Exact Prebid bidder module stems compiled into the external bundle | -| `bundle.modules.user_id` | Curated preset when omitted | Curated User ID module stems compiled into the external bundle | -| `bundle.modules.analytics` | `[]` | Analytics adapter module stems compiled into the external bundle | -| `managed_user_ids[].name` | Required | Prebid `userSync.userIds` entry Trusted Server installs and keeps installed | -| `managed_user_ids[].params` | `{}` | Module-specific parameters, forwarded to Prebid.js unchanged | -| `managed_user_ids[].storage.type` | `cookie` | Browser storage for the module's value: `cookie` or `html5` | -| `managed_user_ids[].storage.name` | Required when `storage` exists | Cookie or local-storage key the module reads and writes | -| `managed_user_ids[].storage.expires` | Prebid's own default | Storage lifetime in days; at least 1. Any per-module ceiling is the module's own | -| `managed_user_ids[].storage.refresh_in_seconds` | Prebid's own default | Seconds before the module may refresh the stored value; at least 1 | - -### Server provider options - -Common fields are `protocol`, `profile`, required HTTPS `endpoint`, optional -`timeout_ms`, and `routing`. The `prebid-server` timeout defaults to 1000 ms; -an explicit provider value overrides it, and the remaining auction budget caps -runtime `tmax`. +| Field | Default | Ownership and behavior | +| ----------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `account_id` | `None` | Optional browser-injected account value | +| `timeout_ms` | `1000` | Browser Prebid.js timeout only | +| `debug` | `false` | Browser Prebid.js debug only | +| `client_side_bidders` | `[]` | Native browser adapters that are not folded into `trustedServer` | +| `excluded_gam_ad_unit_path_suffixes` | `[]` | GAM suffixes omitted from Trusted Server refresh auctions | +| `script_patterns` | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | Publisher Prebid scripts intercepted to prevent duplicate instances | +| `external_bundle_url` | Required when enabled | HTTPS generated bundle URL; host and redirects must be in `proxy.allowed_domains` | +| `external_bundle_sha256` | `None` | Optional content hash used for versioning, cache policy, and ETag | +| `external_bundle_sri` | `None` | Optional SRI metadata | +| `bundle.modules.bidder` | Required and non-empty | Exact Prebid bidder module stems compiled into the external bundle | +| `bundle.modules.user_id` | Curated preset when omitted | Curated User ID module stems compiled into the external bundle | +| `bundle.modules.analytics` | `[]` | Analytics adapter module stems compiled into the external bundle | +| `managed_user_ids[].name` | Required | Prebid `userSync.userIds` entry Trusted Server installs and keeps installed | +| `managed_user_ids[].params` | `{}` | Module-specific parameters, forwarded to Prebid.js unchanged | +| `managed_user_ids[].storage.type` | `cookie` | Browser storage for the module's value: `cookie` or `html5` | +| `managed_user_ids[].storage.name` | Required when `storage` exists | Cookie or local-storage key the module reads and writes | +| `managed_user_ids[].storage.expires` | Prebid's own default | Storage lifetime in days; at least 1. Any per-module ceiling is the module's own | +| `managed_user_ids[].storage.refresh_in_seconds` | Prebid's own default | Seconds before the module may refresh the stored value; at least 1 | + +### Demand source options + +The settings every `[demand.]` table shares are the required `endpoint`, +optional `timeout_ms`, `routing`, and `notifications`. The `prebid_server` +timeout defaults to 1000 ms, an explicit value in the table overrides it, and +the remaining auction budget caps runtime `tmax`. `routing` must stay +`explicit`, because PBS rejects impressions with no routed bidder or +stored-request demand. When migrating an origin-only legacy `server_url`, use that origin as the -provider `endpoint`. The compiler adds `/openrtb2/auction` and preserves query +`endpoint`. The compiler adds `/openrtb2/auction` and preserves query parameters. A configured non-root path, such as `/bid` or `/custom/pbs`, stays exact. `/openrtb2/auction/` is normalized to `/openrtb2/auction`. -The typed `profile_config` fields are: +The settings `prebid_server` adds to its own table are: | Field | Default | Behavior | | -------------------------- | ------- | --------------------------------------------------- | @@ -155,14 +162,14 @@ at most 128 bidder entries. The optional `zone` fact is limited to 256 UTF-8 bytes. Missing, `null`, or empty `bidderParams` invokes Prebid stored-request routing; malformed envelopes do not. -Browser `timeout_ms`/`debug` never inherit a server provider timeout or profile -debug value. Enabling the browser integration does not create a server provider, -and a `prebid-server` provider can exist independently from browser injection. +Browser `timeout_ms` and `debug` never inherit a demand source's timeout or +debug value. Selecting the browser integration does not create a demand source, +and a `prebid_server` demand source can exist without browser injection. ## External Bundle Generation Use `ts prebid bundle` to build the publisher-specific browser bundle from -`[integrations.prebid.bundle.modules]` selections: +`[integration.prebid.bundle.modules]` selections: ```bash ts prebid bundle @@ -206,7 +213,7 @@ that bundle with the server and push its new hash and SRI. The sentinel ### Upgrading from `bundle.adapters` and `bundle.user_id_modules` Before deploying this server version, move the old bundle fields under -`[integrations.prebid.bundle.modules]` and expand short bidder names to exact +`[integration.prebid.bundle.modules]` and expand short bidder names to exact upstream stems. For example, `adapters = ["rubicon"]` becomes `bidder = ["rubiconBidAdapter"]`; `client_side_bidders` continues to use the runtime code `rubicon`. @@ -450,7 +457,7 @@ Use `bid_param_overrides` for static per-bidder param overrides when the same ov **Example**: ```toml -[auction.providers.pbs-main.profile_config.bid_param_overrides.example-server] +[demand.pbs_main.bid_param_overrides.example-server] networkId = 99999 pubid = "example-server-pub" ``` @@ -476,7 +483,7 @@ The JS adapter reads the zone from `mediaTypes.banner.name` on each Prebid ad un **Example**: ```toml -[auction.providers.pbs-main.profile_config.bid_param_zone_overrides.example-server] +[demand.pbs_main.bid_param_zone_overrides.example-server] header = { placementId = "example-header-placement" } in_content = { placementId = "example-content-placement" } fixed_bottom = { placementId = "example-bottom-placement" } @@ -517,7 +524,7 @@ Use `bid_param_override_rules` for the canonical ordered override format. Each r **Example**: ```toml -[[auction.providers.pbs-main.profile_config.bid_param_override_rules]] +[[demand.pbs_main.bid_param_override_rules]] when.bidder = "example-server" when.zone = "header" set = { placementId = "example-header-placement", keep = "example" } @@ -533,7 +540,7 @@ impression or measurement purpose but must not participate in Trusted Server's Prebid refresh auction: ```toml -[integrations.prebid] +[integration.prebid] excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] ``` @@ -577,17 +584,17 @@ owned by Trusted Server. 3. **Client-side bidders** are left as standalone bids — their native Prebid.js adapters handle them in the browser. 4. **Bidders present in `[auction.bidders]`** are absorbed into the `trustedServer` adapter and routed through `/auction` to their configured - provider. Unowned bidders remain native browser demand. + demand source. Unowned bidders remain native browser demand. 5. Both sets of bids compete in the same Prebid.js auction. ### Configuration ```toml -[integrations.prebid] +[integration.prebid] client_side_bidders = ["example-browser"] [auction.bidders.example-server] -provider = "pbs-main" +provider = "pbs_main" ``` Do not route the same bidder through `[auction.bidders]` while also listing it in @@ -600,10 +607,10 @@ Client-side bidders need their exact Prebid.js module stems in the generated bundle: ```toml -[integrations.prebid] +[integration.prebid] client_side_bidders = ["rubicon", "appnexus", "openx"] -[integrations.prebid.bundle.modules] +[integration.prebid.bundle.modules] bidder = ["rubiconBidAdapter", "appnexusBidAdapter", "openxBidAdapter"] user_id = ["sharedIdSystem", "uid2IdSystem"] ``` @@ -668,7 +675,7 @@ Add analytics modules by exact stem. When the pinned Prebid.js package includes ATS, use this build selection: ```toml -[integrations.prebid.bundle.modules] +[integration.prebid.bundle.modules] bidder = ["rubiconBidAdapter"] analytics = ["atsAnalyticsAdapter"] ``` @@ -699,7 +706,7 @@ Trusted Server can own one or more Prebid `userSync.userIds` entries so operators configure identity centrally instead of asking publishers to edit their Prebid JavaScript. -Each `[[integrations.prebid.managed_user_ids]]` entry is forwarded to Prebid.js +Each `[[integration.prebid.managed_user_ids]]` entry is forwarded to Prebid.js verbatim. Trusted Server validates only what Prebid needs to address the module — a usable entry name and storage key, positive expiry and refresh values — and never interprets `params`. Supported names come from the checked-in @@ -728,15 +735,15 @@ is stale, or was modified after generation. Core remains vendor-neutral: it forwards each managed entry's `params` to Prebid.js without interpreting them. ```toml -[integrations.prebid.bundle] +[integration.prebid.bundle] adapters = ["rubicon"] user_id_modules = ["identityLinkIdSystem"] -[[integrations.prebid.managed_user_ids]] +[[integration.prebid.managed_user_ids]] name = "identityLink" params = { pid = "999", notUse3P = false } -[integrations.prebid.managed_user_ids.storage] +[integration.prebid.managed_user_ids.storage] type = "cookie" name = "idl_env" expires = 15 @@ -744,7 +751,7 @@ refresh_in_seconds = 1800 ``` Run `ts prebid bundle`, upload the generated content-addressed bundle, copy its -hash metadata into `[integrations.prebid]`, and validate the configuration +hash metadata into `[integration.prebid]`, and validate the configuration before rollout. ### Worked example: LiveRamp RampID @@ -989,8 +996,8 @@ Optimize mobile ad serving with reduced JavaScript overhead. ## Implementation -Production Prebid Server providers compile from -`[auction.providers.]` into a shared OpenRTB request and response driver. +Production Prebid Server demand sources compile from `[demand.]` into a +shared OpenRTB request and response driver. The browser integration lives in [crates/trusted-server-core/src/integrations/prebid.rs](https://github.com/IABTechLab/trusted-server/blob/main/crates/trusted-server-core/src/integrations/prebid.rs), while provider execution uses diff --git a/docs/guide/integrations/sourcepoint.md b/docs/guide/integrations/sourcepoint.md index 7b9ce9435..61b405eb3 100644 --- a/docs/guide/integrations/sourcepoint.md +++ b/docs/guide/integrations/sourcepoint.md @@ -17,8 +17,10 @@ The Sourcepoint integration: Add the following to `trusted-server.toml`: ```toml -[integrations.sourcepoint] -enabled = true +[integration] +provider = ["sourcepoint"] + +[integration.sourcepoint] rewrite_sdk = true cdn_origin = "https://cdn.privacy-mgmt.com" # Optional: forward a custom Sourcepoint authCookie name upstream. @@ -27,14 +29,13 @@ cache_ttl_seconds = 3600 ``` ::: warning Migration note -The Sourcepoint browser module is now opt-in through `[integrations.sourcepoint].enabled = true`. Existing deployments that relied on unconditional Sourcepoint JavaScript inclusion should enable this integration explicitly before upgrading. +The Sourcepoint browser module is now opt-in through `[integration] provider`. Existing deployments that relied on unconditional Sourcepoint JavaScript inclusion should name `sourcepoint` there before upgrading. ::: ### Configuration Options | Option | Type | Default | Description | | ------------------- | ---------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | boolean | `false` | Enable the Sourcepoint integration | | `rewrite_sdk` | boolean | `true` | Rewrite matching Sourcepoint URLs in HTML | | `cdn_origin` | string | `https://cdn.privacy-mgmt.com` | Sourcepoint CDN origin | | `auth_cookie_name` | string or `null` | `null` | Optional custom Sourcepoint `authCookie` name to forward upstream alongside built-in cookies. Names must be 1-64 characters and contain only letters, numbers, `_`, or `-` | diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md index 3f8b6f4d9..cde347612 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -28,7 +28,7 @@ Key rotation is the process of generating new signing keys and transitioning fro ## Edge Cookie HMAC Passphrase -The Edge Cookie `ec.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters; shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph. +The Edge Cookie `ec.hmac.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters, because shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph. ## Prerequisites diff --git a/docs/guide/permission-model.md b/docs/guide/permission-model.md new file mode 100644 index 000000000..5abfeadfe --- /dev/null +++ b/docs/guide/permission-model.md @@ -0,0 +1,538 @@ +# Permission Model + +Trusted Server runs the Edge Cookie provider only when the technical +permissions it requires are set, and the device and geo providers declare +their requirements the same way. The permission model is +how a deployer's policy decides whether those permissions are set, without that +policy being baked into the core. + +## Privacy is a spectrum + +Privacy is a spectrum, not a binary, and Trusted Server is technology that is +neutral on policy. Different deployers operate under different laws and run +different policies, so it is the deployer who decides how to configure the +stack. Trusted Server provides the mechanism to establish and check permissions, +and the deployer brings the policy that decides how permissions are established +and what they allow. + +The default deployment makes no host-specific call, creates no identifiers, and +resolves no location until an operator enables a provider. It requires exactly +one policy decision, the baseline that applies when no country can be resolved, +which is stated at the top of the `rules:` tree in `permissions.yaml`. Trusted +Server does not assume a jurisdiction for you, so you declare one, and +the examples use the most protective baseline (GDPR-EU). With no Edge Cookie +provider selected there is nothing to gate, so no identifier is created and the +request proceeds. + +## Where the vocabulary comes from + +The permissions are not a vocabulary this project invented. They are the IAB +Tech Lab Privacy Taxonomy Data Uses, with the IAB TCF Europe purposes mapped +onto them where no Data Use exists yet. + +That matters for reading the rest of this guide. When a provider declares the +permissions its data use requires, it is naming a Data Use from that taxonomy, +so an operator or an auditor can check the declaration against the taxonomy +rather than against our interpretation of it. What a deployment decides about a +signal, whether a TCF record answers and what an opt-out takes away, is recorded +in `permissions.yaml` alongside the rules. What a scheme's own signal means, such +as which TCF purpose grants which Data Use, belongs to that scheme's +[permission signal provider](./permission-signals) and is tested there. + +## Separating legal policy from the core + +The core does not encode any jurisdiction's law or any single policy. A provider +advertises the technical permissions its data use requires, and the core runs +the Edge Cookie provider only when every permission that provider requires is +set. An Edge Cookie provider that requires nothing always runs, so a +vendor-neutral default needs no consent prompt and no per-request +policy interaction. + +Device and geo providers declare their required permissions through the same +method. The built-in and host providers require none. A device provider that +an integration module supplies and that declares any permission is refused at +startup, with a message naming the module and the permissions it declared, +because device classification runs before the permission state for the request +is assembled and there is no per-request gate that could honor the +declaration. Refusing it is preferred to recording a requirement that never +binds. A geo declaration is not consulted, because geo runs first and supplies +the country the permission baseline depends on. The only declaration that +decides whether a provider runs per request is the Edge Cookie provider's, in +`ec/mod.rs`. + +## Evidence is not rationed, use is + +Every provider sees all the evidence available for a request. Trusted Server +does not decide which vendor is allowed to see which signal, because +withholding a signal from one vendor and not another would discriminate between +them, and the core stays neutral between vendors. + +What a vendor may do with what it sees is the part that is governed. A provider +declares the permissions its data use requires, and the permission model decides +whether each one is set. So access is universal and use is gated, rather than +the other way round. + +If you are looking for a way to stop a vendor using a signal, the answer is a +permission, not a hidden signal. + +## Permission sources + +Permissions are the single currency every service and provider reads. A provider +never reads consent, a consent framework, or any other source directly. It sees +only the resulting permissions, so it cannot depend on how they were derived. + +```mermaid +flowchart LR + G["Country / region"] --> P[["Permissions
(the stable set)"]] + C["Consent signals
(TCF, GPP, GPC)"] --> P + I["Interaction with
the user"] --> P + X["External data
(extension, profile)"] --> P + P --> S["Providers and services
(Edge Cookie, device, geo)"] +``` + +A request's permissions are set by one or more **permission sources**. Consent +is one source among many, not the basis for every permission: + +- **Country and region.** The baseline position for a jurisdiction, from the geo + provider, keyed by ISO 3166-1 with an optional region such as a US state. When + a region has no rule of its own the country's rule applies, and when no + country is identified, or the country has no rule either, the baseline at the + top of the rules tree applies. That top baseline is required, so there is + always one. +- **Consent and privacy signals.** TCF, GPP, GPC or a US Privacy string read + from the request, mapped onto permissions as a grant or a revoke on top of + the baseline. Each is answered by a [permission signal + provider](./permission-signals), a crate outside the core, asked in the + order configuration gives. +- **Interaction with the user.** A publisher may establish a preference because + it chooses to, not only because a law requires it. +- **Data from another source.** For example a browser extension, or a person's + profile from an external service. + +The model gates on whether a permission is _set_, not on how it was +established, so any of these sources plugs into the same mechanism. + +### Why this matters + +Implementors of services, features, and providers are protected from the method +used to derive the current request's permissions. They work against a clean, +stable set of permissions that does not change when laws, consent frameworks, or +signal sources change. A new GPP section, a new opt-out signal, or a revised +jurisdiction rule changes a _source_, never the permission a provider checks. + +If a source carries a distinction a consumer needs but no existing permission can +express, the fix is to add a permission to the model, never to leak the source +into the consumer. + +## The permission vocabulary + +The permission names are IAB Privacy Taxonomy Data Uses, mapped from the IAB TCF +Europe purposes and used **only** as technical identifiers. No CMP or TCF policy +is implemented in the core. Two purposes have no Data Use yet. Purpose 1 (device +storage) uses a proposed `necessary.operations.storage` key, and purpose 11 +keeps its TCF identifier `select-basic-content`. Both are flagged for an upstream +taxonomy addition. All eleven purposes are resolved against the incoming +consent and privacy signals. A present TCF record grants or revokes each purpose +directly, and a US-style opt-out (GPC, a GPP sale opt-out, or a US Privacy +opt-out) revokes the Data Uses the policy lists, each answered by its own +provider in the order configuration gives, so which of them stands when they +disagree is that order. The remaining taxonomy Data Uses +have no TCF purpose, so no signal maps to them and their configured baseline +stands. What a US-style opt-out revokes, and whether a TCF record answers for +the deployment at all, are declared in the `signals` section of +`permissions.yaml`, so a deployer changes that policy by editing the file. +Which TCF purpose grants which Data Use is not policy but the TCF scheme's own +meaning, so it lives in the TCF [permission signal +provider](./permission-signals) crate, and the core carries no table of another +scheme's numbers. + +`permissions.yaml` carries a policy flag for **every** Data Use in the taxonomy, +not only the eleven below. The eleven have a dedicated identifier because a +provider may gate on them. Every other Data Use is listed for completeness and, +where no informed policy decision has been made, is `denied` by default. Trusted +Server is not the policy authority, so a deployer sets the flags to match its own +jurisdiction rules. + +The eleven named Data Uses, with the TCF purpose each maps from, as the TCF +provider crate maps them: + +| # | Data Use identifier | IAB TCF Europe purpose | +| --- | ----------------------------------------------- | ----------------------------------------------- | +| 1 | `necessary.operations.storage` | Store and/or access information on a device | +| 2 | `advertising_marketing.first_party.contextual` | Use limited data to select advertising | +| 3 | `advertising_marketing.profiling` | Create profiles for personalised advertising | +| 4 | `advertising_marketing.first_party.targeted` | Use profiles to select personalised advertising | +| 5 | `advertising_marketing.personalize.profiling` | Create profiles to personalise content | +| 6 | `advertising_marketing.personalize.content` | Use profiles to select personalised content | +| 7 | `analytics.ad_reporting.measure_ad_performance` | Measure advertising performance | +| 8 | `analytics.ad_reporting.content_performance` | Measure content performance | +| 9 | `analytics.ad_reporting.market_research` | Understand audiences through statistics | +| 10 | `necessary.operations.improve` | Develop and improve services | +| 11 | `select-basic-content` | Use limited data to select content | + +## How providers use permissions + +A provider advertises a required permission set. The core resolves the +permissions it has set for the request, then runs the Edge Cookie provider only +when every permission that provider requires is set. Device and geo providers +advertise a set in the same way, and nothing gates them on it yet, so the table +below lists only the provider whose declaration currently decides whether it +runs. + +| Provider | Requires | Effect when not set | +| ------------------------- | ------------------------------ | ------------------------- | +| Built-in HMAC Edge Cookie | `necessary.operations.storage` | No Edge Cookie is created | +| A vendor-neutral provider | nothing | Always runs | + +The Edge Cookie `Set-Cookie` operation always requires `necessary.operations.storage` +(Purpose 1), because writing the cookie stores information on the device. + +## Groups and rules + +The policy lives in a human-editable permissions YAML document. The repository sample is `config/permissions/sample.yaml`, +compiled into the build, so policy owners read and change it in version control. +That sample is for testing and evaluation only. It is not a production policy +and it is not legal advice, so a deployment reads it, changes it, or replaces +it, with whoever is accountable for that deployment deciding what it says. +It has two parts. **Groups** are named baselines, each a set of permission flags. +**Rules** are a single tree that says which group applies where. + +### Reading and editing the rules tree + +Every node of the tree has a `group`, and children are optional. A child key is +a place code, so the keys directly under `rules:` are ISO 3166-1 alpha-2 country +codes (`FR`, `US`, `GB`), and the keys beneath a country are ISO 3166-2 region +codes with no country prefix (`CA` is California). Codes are matched +case-insensitively, so `us` and `US` name the same place. Where a code sits +also tells two identical codes apart, since `DE` at the first level is Germany +and `DE` under `US` is Delaware. These are the codes a +geo provider returns. The Fastly geo provider emits them directly, and any other +provider must do the same. + +A node can be written two ways. The shorthand is a single string, which becomes +that node's `group` and gives it no children, so `GB: gdpr-uk` is a complete +rule. The longer form is a mapping, which must contain `group:` and may carry +child place codes beside it. A reserved key can never be mistaken for a place, +because an ISO code is at most three characters long. + +Any node may also carry `jurisdiction:` beside its `group:`, naming the consent +handling for the places that node covers. A node that carries none inherits the +nearest ancestor that does. The top node, meaning the `rules:` mapping itself, +is the one node that must carry both, so the inheritance always ends somewhere +and every place in the tree has an answer. The top node is also the answer for a +visitor whose country cannot be resolved, whose baseline is the top `group` and +whose consent handling is the top `jurisdiction`. + +The accepted values are the states of the `Jurisdiction` type in +`crates/trusted-server-core/src/consent/jurisdiction.rs`, which the rest of the +stack already uses: + +| Value | Meaning | Where it may be written | +| --------------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `gdpr` | GDPR handling, being `Jurisdiction::Gdpr`, which appears in logs as `GDPR` | Any node | +| `us-state` | A US state with a comprehensive privacy law, being `Jurisdiction::UsState`. The node names the state, so the value does not | A region node under `US` only | +| `non-regulated` | The place is known and matches no regulation, being `Jurisdiction::NonRegulated` | Any node | +| `unknown` | The jurisdiction cannot be determined, being `Jurisdiction::Unknown` | Any node | + +Anything else is a configuration error rather than a silent default. Write the +value in lower case. `us-state` carries no state code, because the node it sits +on already names the state, which is why it is rejected at the top of the tree +and on a country node, neither of which names one. + +Matching is most specific wins, and `group` and `jurisdiction` fall back the +same way. Trusted Server tries the region node, then the country node, then the +top node, taking the first `group` it finds and the first `jurisdiction` it +finds, which need not come from the same node. A geo lookup **failure** is a +different state from having no location. It keeps the requires-signal floor +described below and its jurisdiction is `unknown`, rather than reaching the tree +at all. + +```yaml +rules: + group: gdpr-eu + # Inherited by every country not overriding it, and the answer when no + # country resolves. + jurisdiction: gdpr + GB: gdpr-uk # inherits gdpr + US: + group: us-notice + jurisdiction: non-regulated + CA: + group: us-opt-out + jurisdiction: us-state + NY: us-notice # inherits non-regulated +``` + +Read that tree as follows. A Manchester visitor gets `gdpr-uk` under GDPR +handling, inherited from the top. A Californian visitor gets `us-opt-out` under +that state's own opt-out handling. A Texas visitor gets `us-notice` under +`non-regulated` handling, both through the `US` node, because no `TX` child +exists. A visitor whose country cannot be resolved gets `gdpr-eu` under GDPR +handling, both stated at the top. + +Startup rejects a file whose top node has no `group` or no `jurisdiction`, in +the same way a missing default country was rejected before, so the unknown case +is always answered in the file rather than assumed by the code. + +Because the tree now says which regime applies where, the older consent +settings `[consent.gdpr] applies_in` and `[consent.us_states] privacy_states` +retire into it. The shipped `permissions.yaml` carries the same 31 GDPR +countries, being the EU 27 plus Iceland, Liechtenstein, Norway and the United +Kingdom, each inheriting `gdpr` from the top node, and the same 20 US states +with a comprehensive privacy law, written as region children of `US` carrying +`jurisdiction: us-state`. One file therefore holds the baselines and the regime +applicability together, so a policy owner reads and changes both in one place +instead of keeping two lists in step. + +The country and region rules set only the **baseline** position. They say what +is permitted before any session signal, not what a deployer must ask the user +for. Session signals are then layered on top, and the deployer's own policy +decides how those signals are gathered. + +Each permission flag in a group is one of three acquisition rules, which a +session signal can then change: + +| Flag | Baseline, and how a session signal changes it | +| ----------------- | ------------------------------------------------------------------- | +| `granted` | Set by default, unless a signal revokes it (for example an opt-out) | +| `requires_signal` | Not set by default, set only when a signal grants it | +| `denied` | Never set, even when a signal grants it | + +A group lists every permission and its flag, so its meaning is explicit in the +file. (A group may instead give a single `default` flag for any permission it +omits, but the shipped groups spell every one out.) A node written as a mapping +may then make small per-permission tweaks on top of its group with a +`permissions` map from Data Use to flag (`granted`, `requires_signal`, or +`denied`), each entry overriding the group baseline for that Data Use. + +### Why three states, not two + +`requires_signal` and `denied` both start unset, so they can look like the same +"off" state, but they answer different questions and a session signal treats +them differently. + +- `requires_signal` means the Data Use is permitted **with** a signal. A grant, + for example TCF consent to the mapped purpose, sets it. +- `denied` means the Data Use is not permitted here at all. A grant **cannot** + set it. This models a jurisdiction where there is no lawful basis for the use, + so a consent signal is irrelevant. + +For a worked example, take a deployer who sets +`advertising_marketing.profiling: denied` for a country that does not permit +profiling. A request arrives with a TCF string that consents to Purpose 3 +(create profiles for personalised advertising), which maps to that Data Use. The +resolver pairs the `denied` baseline with the grant signal and still leaves the +permission **unset**, so a provider that requires profiling does not run. The +same consent against a `requires_signal` baseline would set it. Consent lifts +`requires_signal`; it never lifts `denied`. + +The shipped `permissions.yaml` defines `gdpr-eu`, `gdpr-uk`, and `us-opt-out` +groups, and maps the EU 27 and the EEA members (IS, LI, NO) to `gdpr-eu`, the +UK to `gdpr-uk`, and the US (a country node whose region children are the 20 +states with a comprehensive privacy law, each carrying `jurisdiction: us-state`) +and Australia to `us-opt-out`. For device storage (Purpose 1), that yields: + +| Country | Device storage (Purpose 1) | +| -------------- | --------------------------------------------------------------- | +| EU 27 and EEA | Requires signal (opt-in) | +| United Kingdom | Granted (no signal required under the reformed ePrivacy regime) | +| United States | Granted (opt-out) | +| Australia | Granted | + +These are defaults to modify or replace, not legal advice. The deployer states +the baseline for an unresolved request at the top of the rules tree, through its +`group` and its `jurisdiction`. Both are required and are validated at startup, +so startup fails when either is missing or when the `group` names nothing +defined in the file. A node that names a +group not defined in the file, or a flag that is not `granted`, +`requires_signal`, or `denied`, is rejected at build time, so a typo is caught +rather than silently ignored. + +## How a request resolves + +A permission is _set_ when Trusted Server may rely on it for this request, and +unset otherwise. The Edge Cookie provider runs only when every permission it +requires is set, which is the one place a declaration currently decides whether +a provider runs. + +A consent record that is present but cannot be decoded blocks baseline grants +(fail-closed) rather than degrading to the no-signal baseline, ahead of every +signal provider and whichever are configured. The providers are then asked in +the order `[permission_signal] provider` gives, each amending what the ones +before it settled, and the last with an opinion decides. So which of a US-style +opt-out (GPC, a GPP sale opt-out, or a US Privacy opt-out) and a consenting TCF +record stands when they disagree is the configured order, not a rule in code. +The default order asks Global Privacy Control first, because it is a browser +setting with no interface of its own, and the schemes carrying a choice +someone made through an interface after, so an answer given at a prompt +amends the header the visitor arrived with, and a deployment wanting the +opposite puts the provider it wants to win last. See +[Permission Signals](./permission-signals). Opt-outs suppress use for the +request and never destroy an already-issued identifier. Destructive +withdrawal (the cookie expired and the identity-graph row tombstoned) happens +only when a TCF record refuses storage in a jurisdiction whose baseline did not +grant it. + +```mermaid +flowchart TD + Start[Resolve country and region] --> Lookup{Geo lookup
succeeded?} + Lookup -- "Failed" --> Floor[Requires-signal floor] + Lookup -- "Yes" --> Rules{Region or country
has a node?} + Rules -- "Yes" --> CountryMap[Use that baseline] + Rules -- "No or none resolved" --> Default[Use the top node's group] + Floor --> PerPerm + CountryMap --> PerPerm + Default --> PerPerm + + PerPerm[For each permission] --> Rule{Baseline rule?} + Rule -- "Granted" --> Revoke{Signal
revokes?} + Revoke -- "No" --> Set[Permission set] + Revoke -- "Yes" --> Unset[Permission unset] + Rule -- "Requires signal" --> Grant{Signal
grants?} + Grant -- "Yes" --> Set + Grant -- "No" --> Unset + Rule -- "Denied" --> Unset + + Set --> Check{Provider's required
permissions all set?} + Unset --> Check + Check -- "Yes" --> Run([Run provider]) + Check -- "No" --> Skip([Skip provider]) +``` + +The "Failed" branch is the rule for a geo provider that can report a failed +lookup. None of the providers shipped today can, so a geo outage takes the +"No or none resolved" branch instead. See the note on the top node below. + +## How the resolved permissions reach downstream code + +The permission state is resolved once, at the start of the request cycle in +`EcContext::read_from_request`, before any integration request filter or route +handler runs. Everything downstream reads that one result rather than deriving +its own, so there is a single decision per request. + +**Server side.** An integration request filter receives +`permissions: Option<&PermissionState>` on its `RequestFilterInput`, alongside +the geo result it already receives, so a filter can skip or narrow what it does +when a permission it depends on is unset. Request filters run on the Fastly +adapter today, which is the only adapter that runs the filter step, and there +the state is built before the filters run. None of the shipped filters changes +its behavior on that input yet, so for now the state is carried and available +rather than acted on. + +**Page side.** The same resolved state reaches the page as +`window.tsjs.permissions`, an object listing the Data Use names that are set for +this request. The names are the ones used everywhere else in this guide, being +the `permissions.yaml` keys and `Permission::as_str()`. + +```json +{ + "set": [ + "necessary.operations.storage", + "analytics.ad_reporting.market_research" + ] +} +``` + +Delivery follows the pattern already used for `adSlots` and `bids`, and the +timing depends on how the page is assembled. Under inline assembly the value is +injected as a ` + + diff --git a/tools/permissions-inspector/wasm/.gitignore b/tools/permissions-inspector/wasm/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/tools/permissions-inspector/wasm/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock new file mode 100644 index 000000000..97123760b --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -0,0 +1,2523 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "build-print" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.8#567964158e4f8bd0d52321b9801de44966422e1b" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2", + "thiserror", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.8#567964158e4f8bd0d52321b9801de44966422e1b" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.4", + "toml", + "validator", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "error-stack" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878b3fac9613c3c7f22eb70bc8a3c6ebdc03cc11479ee60fde1692d747fd45f" +dependencies = [ + "anyhow", + "rustc_version", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iab_gpp" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3be2d0191a3376e0176bb3df53b2754c644ead6edd50d9494ee8fa376a70e02" +dependencies = [ + "bitstream-io", + "fnv", + "iab_gpp_derive", + "num-derive", + "num-iter", + "num-traits", + "prettyplease", + "proc-macro2", + "quote", + "strum_macros", + "syn 2.0.119", + "thiserror", + "walkdir", +] + +[[package]] +name = "iab_gpp_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "rsa", + "serde", + "zeroize", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lol_html" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" +dependencies = [ + "bitflags", + "cfg-if", + "cssparser", + "encoding_rs", + "foldhash", + "hashbrown", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror", +] + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permissions-inspector-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trusted-server-core" +version = "0.1.0" +dependencies = [ + "async-stream", + "async-trait", + "base64", + "brotli", + "bytes", + "chacha20poly1305", + "chrono", + "cookie", + "cssparser", + "derive_more", + "ed25519-dalek", + "edgezero-core", + "error-stack", + "flate2", + "futures", + "getrandom 0.2.17", + "glob", + "hex", + "hmac", + "http", + "httpdate", + "iab_gpp", + "jose-jwk", + "log", + "lol_html", + "matchit", + "mime", + "rand", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "subtle", + "toml", + "trusted-server-js", + "trusted-server-openrtb", + "url", + "urlencoding", + "uuid", + "validator", + "web-time", +] + +[[package]] +name = "trusted-server-js" +version = "0.1.0" +dependencies = [ + "build-print", + "hex", + "sha2", + "which", +] + +[[package]] +name = "trusted-server-openrtb" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml new file mode 100644 index 000000000..dee34a3cf --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "permissions-inspector-wasm" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +trusted-server-core = { path = "../../../crates/trusted-server-core" } +# The inspector runs the same permission signal providers an adapter ships, +# in the same default order, so the page answers as a deployment would. +trusted-server-permission-signal-gpc = { path = "../../../crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "../../../crates/permission-signal/gpp" } +trusted-server-permission-signal-tcf = { path = "../../../crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "../../../crates/permission-signal/us-privacy" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 + +# Deliberately its own workspace, like the integration-tests crate, so the +# main workspace's wasm32-wasip1 default target does not apply to it. +[workspace] diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs new file mode 100644 index 000000000..5e8f11cb3 --- /dev/null +++ b/tools/permissions-inspector/wasm/build.rs @@ -0,0 +1,46 @@ +use std::process::Command; + +fn git(args: &[&str]) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Reads the workspace version from the repository root manifest, so the page +/// reports the same version as the trusted-server crates it runs. +fn workspace_version() -> String { + let root = std::fs::read_to_string("../../../Cargo.toml").unwrap_or_default(); + let mut in_package = false; + for line in root.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_package = line == "[workspace.package]"; + } else if in_package && line.starts_with("version") { + if let Some(version) = line.split('"').nth(1) { + return version.to_string(); + } + } + } + String::from("unknown") +} + +fn main() { + println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); + println!( + "cargo:rustc-env=TS_CORE_COMMIT={}", + git(&["rev-parse", "--short=9", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_DATE={}", + git(&["show", "-s", "--format=%cs", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_BRANCH={}", + git(&["rev-parse", "--abbrev-ref", "HEAD"]) + ); + println!("cargo:rerun-if-changed=../../../Cargo.toml"); +} diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs new file mode 100644 index 000000000..1778aa276 --- /dev/null +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -0,0 +1,174 @@ +//! The permissions bit of Trusted Server, compiled to WebAssembly for the +//! inspector page. Inputs in, resulting permissions out, through the same +//! functions the server runs: `build_context_from_signals` decodes the raw +//! consent signals and `assemble_permissions` resolves the policy. + +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::json; +use trusted_server_core::consent::build_context_from_signals; +use trusted_server_core::consent::types::RawConsentSignals; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::PermissionSignalProvider; +use trusted_server_core::permissions::{Permission, PermissionMaps}; +use trusted_server_core::platform::GeoInfo; + +/// The signal providers the inspector asks, in the order every adapter offers +/// them when `[permission_signal] provider` names none. +fn providers() -> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcProvider::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutProvider::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyProvider::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfProvider::new()), + ] +} + +/// The inspector's evaluation request. +#[derive(Deserialize)] +struct EvalInput { + /// `located`, `none`, or `failed`. + geo: String, + country: Option, + region: Option, + tc: Option, + gpp: Option, + us_privacy: Option, + #[serde(default)] + gpc: bool, +} + +fn eval_json(input: &str) -> String { + let input: EvalInput = match serde_json::from_str(input) { + Ok(input) => input, + Err(e) => return json!({"ok": false, "error": e.to_string()}).to_string(), + }; + let signals = RawConsentSignals { + raw_tc_string: input.tc.filter(|s| !s.is_empty()), + raw_gpp_string: input.gpp.filter(|s| !s.is_empty()), + raw_gpp_sid: None, + raw_us_privacy: input.us_privacy.filter(|s| !s.is_empty()), + gpc: input.gpc, + }; + let ctx = build_context_from_signals(&signals); + // The page carries no request, only the consent signals above, and each of + // the four providers answers from the consent record rather than from + // request evidence, so empty evidence changes none of their answers. A + // provider that read a header or a cookie would need real evidence here. + let evidence = OwnedRequestInfo::default(); + let providers = providers(); + let maps = PermissionMaps::standard(); + let (state, jurisdiction) = match input.geo.as_str() { + "failed" => { + let state = assemble_permissions(&ctx, &evidence, GeoStatus::Failed, &providers); + (state, "unknown".to_string()) + } + "none" => { + let state = assemble_permissions(&ctx, &evidence, GeoStatus::NoLocation, &providers); + (state, jurisdiction_name(maps.default_jurisdiction())) + } + _ => { + let info = GeoInfo { + city: String::new(), + country: input.country.clone().unwrap_or_default(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: input.region.clone().filter(|r| !r.is_empty()), + asn: None, + }; + let state = + assemble_permissions(&ctx, &evidence, GeoStatus::Located(&info), &providers); + let jurisdiction = jurisdiction_name( + maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), + ); + (state, jurisdiction) + } + }; + let set: Vec<&'static str> = Permission::all() + .filter(|p| state.is_set(*p)) + .map(Permission::as_str) + .collect(); + json!({ + "ok": true, + "jurisdiction": jurisdiction, + "set": set, + "tcf_decoded": ctx.tcf.is_some(), + "malformed_record": ctx.has_malformed_record(), + }) + .to_string() +} + +fn jurisdiction_name(j: trusted_server_core::consent::jurisdiction::Jurisdiction) -> String { + let name = format!("{j:?}").to_lowercase(); + let name = name.split('(').next().unwrap_or(&name).to_string(); + name.replace("usstate", "us-state") + .replace("nonregulated", "non-regulated") +} + +fn validate_json(yaml: &str) -> String { + match PermissionMaps::from_yaml(yaml) { + Ok(_) => json!({"ok": true}).to_string(), + Err(e) => json!({"ok": false, "error": e.to_string()}).to_string(), + } +} + +fn meta_json() -> String { + json!({ + "version": env!("TS_CORE_VERSION"), + "commit": env!("TS_CORE_COMMIT"), + "date": env!("TS_CORE_DATE"), + "branch": env!("TS_CORE_BRANCH"), + }) + .to_string() +} + +/// Leaks a length-prefixed buffer the host reads and then frees. +fn out(s: String) -> *mut u8 { + let bytes = s.into_bytes(); + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(&bytes); + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_alloc(len: usize) -> *mut u8 { + let mut buf = vec![0u8; len]; + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +/// # Safety +/// `ptr` must come from `ts_alloc` or an `out` buffer with capacity `len`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_free(ptr: *mut u8, len: usize) { + unsafe { drop(Vec::from_raw_parts(ptr, len, len)) }; +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 JSON buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_eval(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(eval_json(core::str::from_utf8(input).unwrap_or("{}"))) +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 YAML buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_validate(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(validate_json(core::str::from_utf8(input).unwrap_or(""))) +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_meta() -> *mut u8 { + out(meta_json()) +} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 67b485016..f1dc9f707 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -1,21 +1,37 @@ # ============================================================================= -# Trusted Server — application configuration template +# Trusted Server application configuration template # ============================================================================= # # This is the source-controlled starting point for an operator-owned # `trusted-server.toml`. Copy it (`ts config init`), fill in the required # values, and push it (`ts config push`) as an EdgeZero app-config blob. # -# Only three sections are REQUIRED for the server to start and pass validation: +# Only two sections are REQUIRED for the server to start and pass validation: # 1. [[handlers]] covering /_ts/admin (admin authentication) # 2. [publisher] (domain + origin) -# 3. [ec] passphrase (Edge Cookie identity secret) # -# Everything below those is OPTIONAL. Most optional blocks are commented out — -# uncomment and edit one to enable it — but a few integrations are kept as active -# `enabled = false` stubs (see the Integrations section for why). All example -# hosts use `example.com`; replace them with your real endpoints in your private -# config, not in this template. +# Edge Cookie identity is optional and stays off until an [ec] provider is +# selected, so no identity secret is needed to start. See the Edge Cookie +# section below. +# +# Everything below those is OPTIONAL. Most optional blocks are commented out, so +# uncomment and edit one to switch it on. All example hosts use `example.com`, +# so replace them with your real endpoints in your private config, not in this +# template. +# +# Everything this deployment can switch on is a provider, and every provider +# type is written the same way: +# +# [] +# provider = "" # a string where one runs, a list where several run +# +# [.] # only when that provider has settings +# setting = "value" +# +# The types are ec, geo, device, permission_signal, demand, adserver and +# integration. A settings table its type's `provider` does not name refuses +# startup, so there is no stale block to misread. The complete rules, and what +# is checked when, are in docs/guide/configuration-rules.md. # # `TRUSTED_SERVER__` env vars can override values when the `ts` CLI builds and # validates this config for push (e.g. TRUSTED_SERVER__PUBLISHER__DOMAIN=...; @@ -28,7 +44,7 @@ # ----------------------------------------------------------------------------- -# REQUIRED — Admin authentication +# REQUIRED - Admin authentication # ----------------------------------------------------------------------------- # HTTP Basic-auth handler(s). At least one handler whose `path` regex covers # the /_ts/admin endpoints is mandatory; startup fails without it. Each handler @@ -50,7 +66,7 @@ password = "handler_password" # ----------------------------------------------------------------------------- -# REQUIRED — Publisher / origin +# REQUIRED - Publisher / origin # ----------------------------------------------------------------------------- [publisher] # Public domain Trusted Server is fronting. @@ -70,21 +86,62 @@ proxy_secret = "publisher_proxy_secret" # ----------------------------------------------------------------------------- -# REQUIRED — Edge Cookie (EC) identity +# OPTIONAL - Edge Cookie (EC) identity # ----------------------------------------------------------------------------- [ec] -# Secret Store key for the secret used to derive EC identifiers. -passphrase = "ec_passphrase" +# Edge Cookie identity is OFF by default. With no provider selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector below, together with that provider's own +# [ec.] table when it has settings (a table the selector does not name +# is rejected at startup). Deployment tooling can merge a +# TRUSTED_SERVER__EC__PROVIDER environment value into the published +# configuration before it is loaded, and the running server itself reads its +# settings from the platform config store rather than the environment. The +# built-in hmac provider is host-neutral, and a vendor provider ships in its +# own crate that the adapter composes in. +# provider = "hmac" # KV store that persists EC identity state. This is the physical store name # bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's # logical KV id is `trusted_server_kv`. ec_store = "ec_identity_store" +# Extra origins allowed to POST the client resolve endpoint. The endpoint +# always accepts https:// and nothing else by default. List +# further origins here if the pages that resolve identity are served from +# another origin, www being the common case. Each entry is a serialized origin +# (RFC 6454) compared by the same-origin test, so the scheme, host and port all +# have to match, and a subdomain is never accepted just for being a subdomain. +# resolve_allowed_origins = ["https://www.example.com"] # Max concurrent partner pull-sync requests. pull_sync_concurrency = 3 # Optional cluster-heuristic tuning (defaults shown): # cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users # cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds +# Built-in HMAC provider table. Uncomment it together with the +# `provider = "hmac"` selector above. `passphrase` names the Secret Store key +# holding the secret used to derive EC identifiers. That secret must be +# >= 32 bytes and non-placeholder in production. Provision referenced keys in +# trusted_server_secrets before validating/pushing. +# [ec.hmac] +# passphrase = "ec_passphrase" + +# A table can name the implementation it configures, which makes the table's +# own name a label of your choosing. The same provider could therefore be +# selected as `provider = "primary"` and configured in the table below +# instead, which is how a deployment gives a provider a name that means +# something to the people running it. +# [ec.primary] +# implementation = "hmac" +# passphrase = "ec_passphrase" + +# Built-in host-signal provider table. Creates the identifier from the host's +# TLS JA4 and HTTP/2 signals together with the client IP, so it needs a host +# that supplies those signals (Fastly does). Uncomment it together with +# `provider = "host_signals"` above, and give it its own secret on the same +# terms as the hmac table. +# [ec.host_signals] +# passphrase = "ec_host_signals_passphrase" + # Example partner configuration. Provision referenced keys in # trusted_server_secrets before validating/pushing. # [[ec.partners]] @@ -102,11 +159,46 @@ pull_sync_concurrency = 3 # batch_rate_limit = 60 # max batch-sync requests/min (default 60) # pull_sync_enabled = false # default false +# Which module resolves location. Leave the whole section out to keep the +# host's own lookup, which is what every adapter ships with today. +# [geo] +# `none` resolves no location at all, or name a registered module that declares +# a geo provider to have that module resolve location instead of the host. +# provider = "none" + # ============================================================================= -# OPTIONAL — Core features (disabled/omitted by default) +# OPTIONAL - Core features (disabled/omitted by default) # ============================================================================= +# Device-detection provider. Selects how a request is classified into device +# signals (browser/bot gating). Default "builtin" classifies from the User-Agent +# alone and makes no host call, so a default deployment stays host-neutral. The +# opt-in "fastly" provider adds the host's TLS/H2 signals to the gate. +# Override at deployment with TRUSTED_SERVER__device__provider. +# [device] +# provider = "builtin" # or "fastly" to add TLS/H2 signal evidence + +[geo] +# Geo / IP intelligence provider. No provider is the default: Trusted Server +# resolves no geolocation and makes no host geo call, so a default deployment is +# not tied to any host geo service. Opt into the host lookup with +# provider = "platform". Override with TRUSTED_SERVER__geo__provider. +# provider = "platform" +# +# The permission baseline for a request the geo provider leaves unmatched +# (and, with no geo provider, for every request) is the top of the rules tree +# in the permissions.yaml compiled into the build (the repository sample is +# config/permissions/sample.yaml). Edit that file to +# change it. +# +# With no geo provider, every request is treated as that top node, so a +# visitor from another jurisdiction receives its permission rules. A +# deployment that runs an Edge Cookie provider without a geo provider must +# acknowledge that by uncommenting the line below, or select a geo provider +# instead. +# assume_single_jurisdiction = true + # Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] # X-Robots-Tag = "noindex" @@ -143,17 +235,17 @@ pull_sync_concurrency = 3 # [tester_cookie] # enabled = false -# Consent forwarding. All values below are the defaults — uncomment to override. +# Consent forwarding. All values below are the defaults, so uncomment one to +# override it. # [consent] # mode = "interpreter" # "interpreter" (decode + forward) or "proxy" (raw passthrough) # check_expiration = true # check TCF consent freshness # max_consent_age_days = 395 # max age before consent is treated as expired (~13 months) # -# [consent.gdpr] -# applies_in = ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"] -# -# [consent.us_states] -# privacy_states = ["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"] +# Which places fall under GDPR, and which US states have a comprehensive +# privacy law, are no longer listed here. They are the `jurisdiction` values in +# the permissions.yaml rules tree, so one file states the policy for both the +# permission baseline and the consent handling. # # [consent.us_privacy_defaults] # notice_given = true # has the publisher shown CCPA notice? @@ -164,19 +256,73 @@ pull_sync_concurrency = 3 # mode = "restrictive" # "restrictive" | "newest" | "permissive" # freshness_threshold_days = 30 -[integrations.js_asset_proxy] -enabled = false +# Which permission signal providers run, and in what order. +# +# Signals compose rather than select. A request can carry a TCF string and a +# Global Privacy Control header at once, and both have something to say, so +# provider here is a list, whereas in [ec], [geo] and [device] it names one +# provider. The order is the policy, because the last provider with an opinion +# decides. +# +# Listed below is every provider the shipped adapters link, which is also +# exactly what runs when provider is left out. Each is a crate under +# crates/permission-signal, outside the core. Providers are named in +# snake_case, lowercase words joined by underscores. Remove the ones this +# publisher does not want to act on. A provider that is not on the list does +# not run, and there is no separate switch to turn one off. An unknown or +# repeated name is refused at startup rather than quietly ignored. None of the +# four has settings, so none needs a [permission_signal.] table. +# +# [permission_signal] +# provider = [ +# "gpc", # the Sec-GPC request header, Global Privacy Control +# "gpp_sale_opt_out", # a GPP US sale opt-out +# "us_privacy", # a US Privacy string sale opt-out +# "tcf", # TCF v2, with its purpose mapping in the crate +# ] +# +# The three opt-outs are separate entries so that a publisher who does not act +# on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy +# opt-outs working. +# +# A consent record that arrives and cannot be read revokes, whichever providers +# are configured. That is error handling rather than a provider to choose, so +# it is not in this list and cannot be removed. +# +# The default order asks Global Privacy Control first, being a browser setting +# with no interface of its own, and the three that carry a choice someone made +# through an interface after, so an answer given at a prompt amends the header +# the visitor arrived with. Reorder the list to change that. +# +# The layering, what a provider may consult, and how to add a scheme are +# documented in crates/trusted-server-core/src/permission_signal/README.md +# and in the Permission Signals guide. + +# Page integration. [integration] provider lists the ones that run, and each +# one that has settings gets its own [integration.] table. An +# integration that is not on the list does not run, and a settings table the +# list does not name refuses startup. The built-in names are datadome, +# didomi, google_tag_manager, gpt, gpt_diagnostics, js_asset_proxy, lockr, +# nextjs, osano, permutive, prebid, sourcepoint and testlight. Add the ones +# this deployment needs to the list below and uncomment their tables further +# down. +[integration] +provider = [] + +# First-party proxying of named third-party scripts. Add "js_asset_proxy" to +# the list above to switch it on. +# [integration.js_asset_proxy] # Uncomment to override upstream cache headers for every asset below. # This replaces upstream directives, including private and no-store. # Use only when each asset's bytes are identical for every visitor. # cache_ttl_seconds = 3600 # Asset fetches use a fixed TrustedServer/1.0 User-Agent. Do not proxy assets # that vary by browser User-Agent or use integrity hashes for UA-specific bytes. - -[[integrations.js_asset_proxy.assets]] -path = "/assets/example-vendor-loader.js" -origin_url = "https://cdn.example.com/vendor-loader.js" -proxy = "enabled" +# +# [[integration.js_asset_proxy.assets]] +# path = "/assets/example-vendor-loader.js" +# origin_url = "https://cdn.example.com/vendor-loader.js" +# proxy = "enabled" # Proxy behavior and first-party asset routing. Kept active with defaults. [proxy] @@ -195,7 +341,7 @@ proxy = "enabled" # prefix = "/.image/" # origin_url = "https://assets.example.com" # Optional path rewrite before sending upstream (the prefix must match what the -# pattern expects — here both use `/.image/`): +# pattern expects, and here both use `/.image/`): # path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" # target_path = "/image/upload/$1.$2" # @@ -254,9 +400,11 @@ proxy = "enabled" # edge_ttl_seconds = 31536000 # immutable = true -# Server-side auction. Providers are declared below; mediator names must match -# enabled integrations. Kept active with the creative-processing leaves present -# so the EdgeZero environment override can apply to them. +# Server-side auction. [auction] holds the settings that belong to the auction +# itself, [demand] selects the demand sources, [adserver] selects the ad server +# that picks the winner, and [auction.bidders.] says which demand source a +# browser bidder code is routed to. Kept active with the creative-processing +# leaves present so the EdgeZero environment override can apply to them. [auction] # Keep disabled until provider endpoints, routes, and profile values below are # replaced with deployment-specific settings. @@ -278,47 +426,65 @@ rewrite_creatives = true # blank slots on a script-heavy demand stack. sanitize_creatives = false timeout_ms = 2000 -# mediator = "adserver_mock" # optional mediator integration # Context keys the JS client may forward into auction requests (allowlist; # empty blocks all). allowed_context_keys = [] -# Example server-side provider declarations. Remove or customize before enabling auctions. -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +# Example demand sources. Remove or customize before enabling auctions. The +# table name is this source's identity, and it is the implementation unless the +# table carries an `implementation` line, which is how two Prebid Servers run +# side by side under names of their own. The built-in implementations are +# openrtb, prebid_server and aps. Every endpoint must be HTTPS, or HTTP to +# 127.0.0.1, ::1 or localhost. +[demand] +provider = ["pbs_main"] + +[demand.pbs_main] +implementation = "prebid_server" endpoint = "https://prebid.example.com/openrtb2/auction" -# `prebid-server` defaults to 1000 ms when omitted. +# `prebid_server` defaults to 1000 ms when omitted. routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false test_mode = false consent_forwarding = "both" -[auction.providers.pbs-main.notifications] +[demand.pbs_main.notifications] suppress_all = false suppress_seats = [] [auction.bidders.example-bidder] -provider = "pbs-main" - -# APS server behavior is also provider/profile-owned. Use routing = "all_eligible" -# when every banner-compatible slot should be eligible. The APS profile timeout -# defaults to 800 ms; debug and script creatives default to false. -# [auction.providers.aps-main] -# protocol = "openrtb-2.6" -# profile = "aps" +provider = "pbs_main" + +# APS is a demand implementation too, not a page integration, so everything it +# owns lives in its own table. Use routing = "all_eligible" when every +# banner-compatible slot should be eligible. The APS timeout defaults to +# 800 ms, and debug, script creatives and publisher-native rendering all +# default to off. Add "aps_main" to the [demand] provider list above when +# uncommenting this. +# [demand.aps_main] +# implementation = "aps" # endpoint = "https://aps.example.com/e/pb/bid" # routing = "all_eligible" -# -# [auction.providers.aps-main.profile_config] # account_id = "example-aps-account-id" # debug = false # allow_script_creatives = false +# rendering_mode = "trusted_server" # inventory_domain = "publisher.example" # inventory_page_origin = "https://www.publisher.example" +# The ad server that picks the auction winner. With no [adserver] selection the +# highest bid wins. The only implementation this repository ships is +# adserver_mock, for development and testing. +# [adserver] +# provider = "adserver_mock" +# +# [adserver.adserver_mock] +# endpoint = "https://adserver.example.com/decide" +# timeout_ms = 1000 +# Map auction context keys to decision-URL query params: +# [adserver.adserver_mock.context_query_params] +# example_segments = "segments" + # Server-side ad slot templates + creative-opportunity auction. Kept active. [creative_opportunities] # Set false to disable server-side ad templates while keeping slot definitions @@ -331,7 +497,7 @@ provider = "pbs-main" enabled = true gam_network_id = "123456789" price_granularity = "dense" -# FCP is not affected by this value — body content above has already +# FCP is not affected by this value, because body content above has already # streamed and painted before the hold begins. What this caps is the slip on # DOMContentLoaded and window.load. 500 ms is the recommended default; raise # only if your SSPs need more headroom and analytics confirm the DCL slip is OK. @@ -345,7 +511,7 @@ auction_timeout_ms = 500 # assembly_mode = "inline" # Request headers (besides Accept-Encoding) the origin may name in Vary. Every # emitted Vary name must be covered here or template storage is refused. Never -# include Cookie — shared templates must be reader-neutral. +# include Cookie, because shared templates must be reader-neutral. # template_cache_vary = [ # "rsc", # "next-router-state-tree", @@ -426,20 +592,20 @@ auction_timeout_ms = 500 # auction_dataset = "auction_events" # Events API datasource name # auction_token_secret = "tinybird_auction_append_token" # Key in trusted_server_secrets -# Debug endpoints (all default false — never enable in production). +# Debug endpoints (all default false, and never enable one in production). # [debug] # Exposes GET /_ts/debug/ja4 returning TLS/JA4 fingerprint details. Disable # after investigation; it reflects data browser JS cannot normally read. # ja4_endpoint_enabled = false # Injects a `` auction dump before ``. NEVER enable # in production: even the default "redacted" mode still includes bid-level fields -# and creative previews — it is not a fully anonymized dump. +# and creative previews, so it is not a fully anonymized dump. # auction_html_comment = false # # Fine-grained control over the dump (all optional; defaults shown). # [debug.auction_html_comment_options] # include_provider_responses = true -# include_mediator_response = true +# include_adserver_response = true # include_bids = true # Subset of the fixed validated metadata keys shown in "redacted" (and, for these # three keys, "upstream") mode. Any other key fails config load. @@ -455,35 +621,29 @@ auction_timeout_ms = 500 # ============================================================================= -# OPTIONAL — Integrations +# OPTIONAL - Integration settings # ============================================================================= -# Every integration is off by default. Most are fully commented out; uncomment a -# block and set `enabled = true` to activate it. Four (gpt, didomi, datadome, -# google_tag_manager) are kept as active `enabled = false` stubs so the `ts audit` -# CLI can flip them in place — leave those sections present. Integrations whose -# `enabled` defaults to true (prebid, permutive, lockr, ...) still stay OFF while -# their section is commented out. Required fields are noted per block. +# Every integration is off by default, because [integration] provider above is +# empty. To switch one on, add its name to that list and uncomment its settings +# table below. An integration with nothing to set needs no table at all. A +# table the list does not name refuses startup, so a block left behind after an +# integration is switched off is caught rather than sitting unread. Required +# fields are noted per table. # ============================================================================= - -# Browser-side Prebid.js integration. Server-side bidder routing belongs under -# [auction.providers] and [auction.bidders]. When enabled, -# `external_bundle_url` is required and its host must be listed in -# [proxy].allowed_domains. Kept active but disabled. -[integrations.prebid] -enabled = false -timeout_ms = 1000 -debug = false -client_side_bidders = [] # bidders running via native Prebid.js adapters +# [integration.prebid] +# timeout_ms = 1000 +# debug = false +# client_side_bidders = [] # bidders running via native Prebid.js adapters # Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions. # Matching slots still refresh through GAM. # excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] -# Runtime bundle metadata — set after running `ts prebid bundle` and uploading: +# Runtime bundle metadata, set after running `ts prebid bundle` and uploading: # external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" # external_bundle_sha256 = "" # external_bundle_sri = "" # Bundle build inputs consumed by `ts prebid bundle`, not by the edge runtime. # Values are exact upstream module stems without `.js`. -# [integrations.prebid.bundle.modules] +# [integration.prebid.bundle.modules] # bidder = ["rubiconBidAdapter"] # Omit user_id for the curated default preset; use [] to select none. # user_id = ["sharedIdSystem"] @@ -498,7 +658,7 @@ client_side_bidders = [] # bidders running via native Prebid.js adapter # the checked-in User ID registry. Each `name` must appear only once. # # The module must be present in the built bundle: name it under -# [integrations.prebid.bundle].user_id_modules, or omit that list to take the +# [integration.prebid.bundle.modules].user_id, or omit that list to take the # generator's default preset. `ts prebid bundle` resolves every managed name # through the checked-in User ID registry and fails if the generated manifest # omits its required module without updating the configured hash or SRI. @@ -508,63 +668,58 @@ client_side_bidders = [] # bidders running via native Prebid.js adapter # OpenRTB EID source; without one the ID still reaches the auction but is never # written to KV. # -# [[integrations.prebid.managed_user_ids]] +# [[integration.prebid.managed_user_ids]] # name = "sharedId" # -# [integrations.prebid.managed_user_ids.storage] +# [integration.prebid.managed_user_ids.storage] # type = "cookie" # or "html5" # name = "_sharedid" # expires = 15 # days; omit to keep Prebid's default # refresh_in_seconds = 1800 # omit to keep Prebid's default # Next.js first-party rewriting for App Router / RSC payloads. -# [integrations.nextjs] -# enabled = true +# [integration.nextjs] # rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] # max_combined_payload_bytes = 10485760 # 10 MiB -# Testlight OpenRTB test integration. `endpoint` required when enabled. -# [integrations.testlight] -# enabled = true +# Testlight OpenRTB test integration. `endpoint` is required. +# [integration.testlight] # endpoint = "https://testlight.example.com/openrtb2/auction" # timeout_ms = 1200 # rewrite_scripts = true -# Didomi CMP SDK/API first-party proxy. Kept active but disabled so `ts audit` -# can flip `enabled` to true when Didomi is detected on the audited page. -[integrations.didomi] -enabled = false -geo_query_parameters = false +# Didomi CMP SDK/API first-party proxy. `ts audit` adds "didomi" to the +# [integration] provider list and uncomments this table when it detects Didomi +# on the audited page. +# [integration.didomi] +# geo_query_parameters = false # sdk_origin = "https://sdk.example.com" # api_origin = "https://api.example.com" # Sourcepoint CMP first-party proxy. # `cdn_origin` is intentionally omitted: the upstream CDN origin is pinned to # Sourcepoint's own host and is not operator-selectable. Leave it unset so the -# validated default applies — overriding it with any other host fails config -# validation. -# [integrations.sourcepoint] -# enabled = true +# validated default applies, because overriding it with any other host fails +# config validation. +# [integration.sourcepoint] # rewrite_sdk = true # cache_ttl_seconds = 3600 # auth_cookie_name = "sp_auth" # optional: forward a custom authCookie upstream -# Osano consent management (proxy toggle only). -# [integrations.osano] -# enabled = true +# Osano consent management. It has nothing to set, so naming "osano" in the +# [integration] provider list above is the whole configuration and it needs no +# table of its own. -# Permutive DMP. `organization_id` and `workspace_id` required when enabled. -# [integrations.permutive] -# enabled = true +# Permutive DMP. `organization_id` and `workspace_id` are required. +# [integration.permutive] # organization_id = "your-permutive-organization-id" # required (non-empty) # workspace_id = "your-permutive-workspace-id" # required (non-empty) # project_id = "your-permutive-project-id" # api_endpoint = "https://api.example.com" # secure_signals_endpoint = "https://secure-signals.example.com" -# lockr identity SDK. `app_id` required when enabled. -# [integrations.lockr] -# enabled = true +# lockr identity SDK. `app_id` is required. +# [integration.lockr] # app_id = "your-lockr-app-id" # required (non-empty) # api_endpoint = "https://identity.example.com" # sdk_url = "https://identity.example.com/trusted-server.js" @@ -572,9 +727,9 @@ geo_query_parameters = false # rewrite_sdk = true # DataDome bot protection. Proxies tags.js + signal-collection API first-party. -# Kept active but disabled so `ts audit` can flip `enabled` when detected. -[integrations.datadome] -enabled = false +# `ts audit` adds "datadome" to the [integration] provider list and uncomments +# this table when it detects DataDome. +# [integration.datadome] # sdk_origin = "https://sdk.example.com" # api_origin = "https://api.example.com" # cache_ttl_seconds = 3600 @@ -596,45 +751,35 @@ enabled = false # API validation and is stripped before the origin sees it. The credential is # loaded from the Secret Store at runtime (>= 32 bytes of high-entropy material). # Never enable in production. -# [integrations.datadome.protection_test_bypass] +# [integration.datadome.protection_test_bypass] # enabled = true # credential_secret_store = "ts_secrets" # credential_secret_name = "datadome_protection_test_bypass" -# Google Publisher Tag (GPT) first-party proxy. Kept active but disabled so -# `ts audit` can flip `enabled` to true when GPT is detected. -[integrations.gpt] -enabled = false -# Kept as an active leaf so the environment override can apply; the overlay -# cannot create a missing configuration leaf. GAM attribution stays off until -# this is set true. -gam_attribution_enabled = false +# Google Publisher Tag (GPT) first-party proxy. `ts audit` adds "gpt" to the +# [integration] provider list and uncomments this table when it detects GPT. +# GAM attribution stays off until `gam_attribution_enabled` is set true. The +# environment overlay cannot create a missing leaf, so uncomment the line +# before relying on TRUSTED_SERVER__INTEGRATION__GPT__GAM_ATTRIBUTION_ENABLED. +# [integration.gpt] +# gam_attribution_enabled = false # script_url = "https://ads.example.com/gpt.js" # cache_ttl_seconds = 3600 # rewrite_script = true -# GPT runtime diagnostics browser overlay. Optional and enabled manually (not -# flipped by `ts audit`); serves a diagnostics module gated behind an activation -# query param + session cookie. -# [integrations.gpt_diagnostics] -# enabled = true - -# APS browser renderer ownership. Server-side APS behavior belongs under an -# [auction.providers] entry with profile = "aps". -# [integrations.aps] -# enabled = true -# Default `trusted_server` uses TS's opaque static renderer route. Set -# `publisher_native` only for a controlled publisher-origin friendly-frame cohort. -# rendering_mode = "trusted_server" - -# Google Tag Manager first-party proxy. Kept active but disabled so `ts audit` -# can fill container_id and flip `enabled` when GTM is detected. `container_id` -# is required when this integration is actually enabled. -[integrations.google_tag_manager] -enabled = false -# Invalid placeholder on purpose: enabling GTM without a real GTM-XXXXXX id -# fails validation. `ts audit` overwrites this when it detects a real container. -container_id = "GTM-REPLACE-ME" +# GPT runtime diagnostics browser overlay. Selected manually rather than by +# `ts audit`, it serves a diagnostics module gated behind an activation query +# param and a session cookie. It has nothing to set, so naming +# "gpt_diagnostics" in the [integration] provider list above is the whole +# configuration. + +# Google Tag Manager first-party proxy. `ts audit` adds "google_tag_manager" to +# the [integration] provider list, uncomments this table and fills container_id +# when it detects GTM. `container_id` is required. +# [integration.google_tag_manager] +# Invalid placeholder on purpose, because selecting GTM without a real +# GTM-XXXXXX id fails validation. +# container_id = "GTM-REPLACE-ME" # Must be a credential-free https bare origin with a literal host: no path, # query, fragment, `user:pass@` or wildcard. Targets are built by appending a # rooted path to this value, so anything beyond an origin corrupts them. @@ -651,11 +796,3 @@ container_id = "GTM-REPLACE-ME" # provides. Find the ids by grepping your rendered HTML for `gtag/js?id=`. # allowed_tag_ids = ["G-EXAMPLE1", "AW-000000", "MC-EXAMPLE1"] -# Mock ad server used for auction mediation in dev/testing. -# [integrations.adserver_mock] -# enabled = true -# endpoint = "https://adserver.example.com/mediate" -# timeout_ms = 1000 -# Map auction context keys to mediation URL query params: -# [integrations.adserver_mock.context_query_params] -# example_segments = "segments"