diff --git a/.cargo/config.toml b/.cargo/config.toml index aea0df2d59..55a08bd79b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,20 +26,22 @@ 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-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -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-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -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-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -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-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # Feature-on counterpart of `test-fastly`. `test-fastly` does NOT pass # --all-features, so without this the reusable-sandbox code is linted by # `clippy-fastly` but its tests never execute. test-fastly-reuse = "test -p trusted-server-adapter-fastly --target wasm32-wasip1 --features reusable-sandbox" # --- Axum adapter (native dev server) --- +# Test and clippy also cover the permission signal crates, whose own tests +# run with the Axum adapter in CI. build-axum = "build -p trusted-server-adapter-axum" check-axum = "check -p trusted-server-adapter-axum" -clippy-axum = "clippy -p trusted-server-adapter-axum --all-targets --all-features -- -D warnings" -test-axum = "test -p trusted-server-adapter-axum" +clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); diff --git a/.claude/skills/planning-prebid-aws/references/configuration-and-secrets.md b/.claude/skills/planning-prebid-aws/references/configuration-and-secrets.md index f0ff876d6c..d8d53ae0e2 100644 --- a/.claude/skills/planning-prebid-aws/references/configuration-and-secrets.md +++ b/.claude/skills/planning-prebid-aws/references/configuration-and-secrets.md @@ -12,17 +12,17 @@ Before asking questions the repository can answer: 4. Add observations to the existing deployment decision record with source path/key, candidate requirement, confidence/status, and the remaining question. Report credential identifiers or presence only; keep raw TOML, credential values, and commercially sensitive publisher values out of reports and generated examples. 5. Verify candidate adapters against the selected PBS Go release and confirm partner authorization. Record each host-secret requirement as required, not needed, or unresolved. A bidder name alone proves neither a secret requirement nor permission to use the bidder. -| Trusted Server input | Discovery use | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | -| `auction.enabled` | Determine active versus disabled server-auction intent without expanding defaults | -| `auction.providers.` with `profile = "prebid-server"` | Identify each configured PBS provider; withhold endpoint values | -| Provider `timeout_ms` and `profile_config.test_mode` or `debug` | Record explicit timeout and diagnostic intent, not permission to contact bidders | -| Provider `profile_config.bid_param_override_rules` | Count conditional publisher or placement inputs without reporting their values | -| `auction.bidders..provider` | Group candidate server-side adapters under the referenced PBS provider | -| `integrations.prebid.enabled`, `account_id`, `timeout_ms`, `debug` | Record browser integration intent and compatibility questions separately from server demand | -| `integrations.prebid.client_side_bidders` | Browser-side participation; do not automatically enable these adapters in PBS | -| `integrations.prebid.bundle` | Browser bundle and identity capabilities, not proof of server-side use | -| Relevant privacy, format, and stored-request settings | Identify dependencies that need confirmation from the caller or request path | +| Trusted Server input | Discovery use | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `auction.enabled` | Determine active versus disabled server-auction intent without expanding defaults | +| `demand.` with `implementation = "auction.prebid-server"` | Identify each configured PBS demand source and whether `demand.modules` selects it; withhold endpoint values | +| Demand source `timeout_ms`, `test_mode` and `debug` | Record explicit timeout and diagnostic intent, not permission to contact bidders | +| Demand source `bid_param_override_rules` | Count conditional publisher or placement inputs without reporting their values | +| `auction.bidders..module` | Group candidate server-side adapters under the referenced PBS demand source | +| `prebid` in `auction.modules`, and `auction.prebid` `account_id`, `timeout_ms`, `debug` | Record browser integration intent and compatibility questions separately from server demand | +| `auction.prebid.client_side_bidders` | Browser-side participation; do not automatically enable these adapters in PBS | +| `auction.prebid.bundle` | Browser bundle and identity capabilities, not proof of server-side use | +| Relevant privacy, format, and stored-request settings | Identify dependencies that need confirmation from the caller or request path | Use `ts prebid server inspect --config ` for redacted local discovery. It reports explicit values without expanding defaults or proving host-secret requirements. Resolve schema defaults and adapter metadata separately, recording unsupported details as unresolved rather than fetching credentials or inventing mappings. Read config without invoking commands that publish or rewrite it. Leave `trusted-server.toml` unchanged. Propose caller endpoint/account changes separately after integration approval; discovery must not activate bidders, publish configuration, or change traffic. diff --git a/.env.example b/.env.example index a7f5973cdb..8a874e292c 100644 --- a/.env.example +++ b/.env.example @@ -40,41 +40,36 @@ TRUSTED_SERVER__REQUEST_SIGNING__ENABLED=false # ============================================================================= # Integrations # ============================================================================= +# Which integrations run is set in the TOML file, in the section of each +# one's type, as `[auction] modules = ["prebid"]` and +# `[framework] module = "nextjs"`. There is no per-integration enabled flag. +# An overlay below changes a setting the file already carries, and it cannot +# add one the file leaves out or replace a list. An overlay names its section +# as the file writes it, so a section with a hyphen in its name, such as +# `[ad-tag]`, is set with `env 'NAME=value' command`, because a shell's +# `export` refuses the name. -# 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 belongs to a demand +# source, so its overlay is TRUSTED_SERVER__DEMAND____ENDPOINT. +# TRUSTED_SERVER__AUCTION__PREBID__ACCOUNT_ID= +# TRUSTED_SERVER__AUCTION__PREBID__TIMEOUT_MS=1000 +# TRUSTED_SERVER__AUCTION__PREBID__DEBUG=false # Next.js -TRUSTED_SERVER__INTEGRATIONS__NEXTJS__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__NEXTJS__REWRITE_ATTRIBUTES=href,link,url +# TRUSTED_SERVER__FRAMEWORK__NEXTJS__MAX_COMBINED_PAYLOAD_BYTES=10485760 # 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__CMP__DIDOMI__SDK_ORIGIN=https://sdk.privacy-center.org +# TRUSTED_SERVER__CMP__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__AUDIENCE__PERMUTIVE__ORGANIZATION_ID= +# TRUSTED_SERVER__AUDIENCE__PERMUTIVE__WORKSPACE_ID= +# TRUSTED_SERVER__AUDIENCE__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__IDENTITY__LOCKR__APP_ID= +# TRUSTED_SERVER__IDENTITY__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 0000000000..5d58b3d93e --- /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 95654ecddf..453bf4d5a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,8 +72,12 @@ jobs: run: BID_DELAY=3 ./scripts/template-cache-local-test.sh purge 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 @@ -122,12 +126,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 24b9e06aa1..a4a0af36c7 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 dd1dd52337..0cd8d0afe3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,28 @@ JavaScript injection. ``` crates/ - trusted-server-core/ # Core library — shared logic, integrations, HTML processing + trusted-server-core/ # Core library — shared logic, the integration seam, HTML processing trusted-server-adapter-fastly/ # Fastly Compute entry point (wasm32-wasip1 binary) trusted-server-adapter-axum/ # Axum dev server entry point (native binary) 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 + trusted-server-modules/ # the modules a stock build ships, in the order their hooks run + ad-server/ # ad server implementation crates (mock) + ad-tag/ # ad tag module crates (google, which holds its diagnostics module too) + auction/ # auction module crates (aps, prebid, prebid-server) + audience/ # audience module crates (permutive) + bot-protection/ # bot protection module crates (datadome) + cmp/ # consent tool module crates (didomi, osano, sourcepoint) + framework/ # framework module crates (nextjs) + identity/ # identity module crates (lockr) + tag/ # tag manager module crates (google-tag-manager) + testing/ # test module crates (seam-probe, testlight) + device/ + fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device module) + edgecookie/ # vendor Edge Cookie module crates (built-in HMAC module is in core) + geo/ # vendor geo module crates (host geo is injected by the adapter) + permission-signal/ # permission signal module 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 ``` @@ -61,7 +77,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 @@ -160,6 +178,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 @@ -287,12 +323,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 module 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 module that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated module **runs once its required permissions are set**, by whatever + method. + +**Evidence is not rationed, use is.** Every module 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 module'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 @@ -309,6 +381,54 @@ Bad: `"fix: added feature flags"` --- +## Module 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 `module` key that selects what runs (`modules` +where several run), and a `[.]` +table for a selected name 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 module configuration. + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | `hmac`, `host_signals`, `client_fixed` (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | `builtin`, User-Agent only (the default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | None, no location (the default), or `platform` | `crates/geo/` | +| Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | `gpc`, `gpp`, `us-privacy`, `tcf`, all of them with no list | `crates/permission-signal/` | +| Auction demand | `DemandImplementation` (`auction/demand.rs`) | `[demand] modules` (a list) | `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` | an integration builder | +| Ad server | `AdServerImplementation` (`auction/demand.rs`) | `[ad-server] module` | `mock` (`ad-server.mock`) | an integration builder | +| Page integrations | `IntegrationBuilder` (`integrations/mod.rs`) | the section of the module's type: `[cmp] module`, `[tag] modules`, `[ad-tag] modules`, `[bot-protection] module`, `[identity] module`, `[audience] module`, `[framework] module`, `[auction] modules`, `[proxy] modules` | `cmp.didomi`, `cmp.sourcepoint`, `cmp.osano`, `tag.google-tag-manager`, `ad-tag.google`, `ad-tag.google.diagnostics`, `bot-protection.datadome`, `identity.lockr`, `audience.permutive`, `framework.nextjs`, `auction.prebid`, `testing.testlight`, `js_asset_proxy` | an adapter-supplied builder | + +`auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` and `ad-server.mock` supply implementations +only. No module section selects them. + +Principles for adding or changing a module: + +- **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_module` + and `build_geo_module`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Modules read request evidence, not a fixed parameter set.** A module 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 module triggers no + host signal calls. +- **Modules 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 module + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + multiple vendor modules share a backend (for example a vendor's Edge Cookie, + geo, and device module on one cloud pipeline) they share a single call per + request rather than calling independently. Give a module the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -322,7 +442,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 the section of its type selects its module. A module with a crate of its own is named by that crate's path under `crates/` (`permission-signal.gpc`, `testing.seam-probe`). Each page integration lives in a crate of its own and holds that name in a `MODULE` constant (`cmp.didomi`, `tag.google-tag-manager`, `ad-tag.google`, `bot-protection.datadome`, `identity.lockr`, `audience.permutive`, `framework.nextjs`, `auction.prebid`, `testing.testlight`), which a test in the crate holds to the crate's folder. A name is written in its section with the type left off. There is no `enabled` flag and no `[integration]` table. +- `creative` is JS-only (no Rust registration), and `nextjs` is Rust-only. `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` and `ad-server.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 `", - tsjs::tsjs_single_module_script_src(GPT_DIAGNOSTICS_INTEGRATION_ID) + tsjs::tsjs_single_module_script_src(module) ) }) } - /// An active decision, for tests in other modules that need one. - /// - /// The fields are private and built by `prepare_request` from a cookie or query - /// parameter; there is no other way to obtain an active decision across a module - /// boundary. - #[cfg(test)] - pub(crate) fn active_for_tests() -> Self { - Self { - active: true, - clean_browser_path_and_query: None, - cookie_action: GptDiagnosticsCookieAction::None, - } - } } #[cfg(test)] mod head_seam_invariant_tests { use super::*; + /// The compile-time diagnostics module, as the registry would serve it. + fn diagnostics_part() -> JsModulePart { + JsModulePart::compile_time(GPT_DIAGNOSTICS_INTEGRATION_ID) + .expect("should have compiled the diagnostics module in") + } + /// Every combination of the three fields the decision carries. fn all_decisions() -> Vec { let mut out = Vec::new(); @@ -162,9 +178,10 @@ mod head_seam_invariant_tests { // // If a future change makes a script emit without also requiring the stamp, // this fails here rather than silently in a cached template. + let module = diagnostics_part(); for decision in all_decisions() { - let injects = - decision.bootstrap_script().is_some() || decision.module_script_tag().is_some(); + let injects = decision.bootstrap_script().is_some() + || decision.module_script_tag(&module).is_some(); if injects { assert!( decision.requires_private_no_store(), @@ -184,7 +201,7 @@ mod head_seam_invariant_tests { "should not inject a bootstrap for an inert decision" ); assert_eq!( - decision.module_script_tag(), + decision.module_script_tag(&diagnostics_part()), None, "should not inject a module for an inert decision" ); @@ -209,7 +226,20 @@ struct ConsoleCookieState { canonical: bool, } -/// Register GPT diagnostics when explicitly enabled. +/// Validates the GPT diagnostics configuration for deployment and reports whether +/// a section selects the integration's module. +/// +/// # Errors +/// +/// Returns an error when the GPT diagnostics configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .module_config::(MODULE) + .map(|config| config.is_some()) +} + +/// Register GPT diagnostics when a section selects it. /// /// # Errors /// @@ -218,32 +248,52 @@ struct ConsoleCookieState { pub fn register( settings: &Settings, ) -> Result, Report> { - let Some(_config) = - settings.integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID)? - else { + let Some(_config) = settings.module_config::(MODULE)? else { return Ok(None); }; Ok(Some( IntegrationRegistration::builder(GPT_DIAGNOSTICS_INTEGRATION_ID) - .without_js() + .with_standalone_js() + .with_head_injector(Arc::new(DiagnosticsHead)) .build(), )) } -/// Whether the diagnostics integration is present and enabled in configuration. -/// -/// This is the deployment-level switch, not the per-document activation state: -/// callers that only need to know whether diagnostics could consume a value use -/// this, while document behaviour uses [`GptDiagnosticsRequestDecision::active`]. -/// A configuration that cannot be parsed reads as disabled. -#[must_use] -pub fn is_enabled(settings: &Settings) -> bool { - settings - .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID) - .ok() - .flatten() - .is_some() +/// Writes what an active decision adds to a document's head. +struct DiagnosticsHead; + +impl DiagnosticsHead { + /// The decision the preparer left for this document, when it left one. + fn decision(ctx: &IntegrationHtmlContext<'_>) -> Option> { + ctx.document_state + .get::(GPT_DIAGNOSTICS_INTEGRATION_ID) + } +} + +impl IntegrationHeadInjector for DiagnosticsHead { + fn integration_id(&self) -> &'static str { + GPT_DIAGNOSTICS_INTEGRATION_ID + } + + fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + Self::decision(ctx) + .and_then(|decision| decision.bootstrap_script()) + .into_iter() + .collect() + } + + /// The module loads synchronously after the bundle, so its listeners are + /// in place before the publisher's scripts in the origin head run. + fn after_bundle_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + Self::decision(ctx) + .and_then(|decision| { + JsModulePart::compile_time(GPT_DIAGNOSTICS_INTEGRATION_ID) + .and_then(|module| decision.module_script_tag(&module)) + }) + .into_iter() + .collect() + } } /// Evaluate activation and sanitize the request before generic cookie handling. @@ -264,15 +314,15 @@ pub fn prepare_request( return Ok(existing.clone()); } - let integration_enabled = settings - .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID)? + let integration_runs = settings + .module_config::(MODULE)? .is_some(); let (directive, clean_path, had_reserved_query) = console_query(request.uri()); let cookie_state = console_cookie_state(request); let eligible_navigation = request.method() == Method::GET && is_navigation_request(request) - && !crate::publisher::is_prefetch_request(request) - && !crate::publisher::is_bot_user_agent(request); + && !trusted_server_core::publisher::is_prefetch_request(request) + && !trusted_server_core::publisher::is_bot_user_agent(request); sanitize_console_cookie(request); if had_reserved_query { @@ -280,7 +330,7 @@ pub fn prepare_request( } let mut decision = GptDiagnosticsRequestDecision::default(); - if integration_enabled && eligible_navigation && had_reserved_query { + if integration_runs && eligible_navigation && had_reserved_query { decision.clean_browser_path_and_query = Some(clean_path); match directive { QueryDirective::Enable => { @@ -292,7 +342,7 @@ pub fn prepare_request( } QueryDirective::Invalid | QueryDirective::Absent => {} } - } else if integration_enabled + } else if integration_runs && directive == QueryDirective::Absent && cookie_state.occurrences == 1 && cookie_state.canonical @@ -302,17 +352,38 @@ pub fn prepare_request( } request.extensions_mut().insert(decision.clone()); + // Only a decision that changes the document or the response is left for + // the page path, so an ordinary request stays on the shared template path. + if decision.requires_private_no_store() { + IntegrationRequestState::insert(request, GPT_DIAGNOSTICS_INTEGRATION_ID, decision.clone()); + } Ok(decision) } -/// Read the request decision, defaulting to inactive when not prepared. -#[must_use] -pub fn request_decision(request: &Request) -> GptDiagnosticsRequestDecision { - request - .extensions() - .get::() - .cloned() - .unwrap_or_default() +/// Builder hook: prepares the request and discards the decision, which stays +/// in the request extensions for the publisher path to read. +/// +/// # Errors +/// +/// Returns the error from [`prepare_request`]. +pub(crate) fn prepare_request_hook( + settings: &Settings, + request: &mut Request, +) -> Result<(), Report> { + prepare_request(settings, request).map(|_| ()) +} + +/// Builder hook: finalizes the response from the decision the preparer left +/// for the page path, and leaves the response alone when it left none. +pub(crate) fn finalize_response_hook( + request_state: &IntegrationRequestState, + response: &mut Response, +) { + if let Some(decision) = + request_state.get::(GPT_DIAGNOSTICS_INTEGRATION_ID) + { + finalize_response(&decision, response); + } } /// Apply activation cookie and strict cache privacy to an origin response. @@ -339,7 +410,7 @@ pub fn finalize_response( // `RequestFilterEffects` mutation such as `Cache-Control: public` replaced it, // and the adapter's terminal guard had no marker to re-enforce from, so // request-scoped diagnostics HTML became shared-cacheable. - enforce_synthesized_html_cache_privacy(response); + enforce_terminal_private_cache_privacy(response); } } @@ -436,19 +507,13 @@ fn replace_path_and_query( #[cfg(test)] mod tests { use super::*; - use crate::integrations::IntegrationRegistry; - use crate::test_support::tests::create_test_settings; use serde_json::json; + use trusted_server_core::integrations::IntegrationRegistry; + use trusted_server_core::test_support::tests::create_test_settings; - fn settings(enabled: bool) -> Settings { + fn settings() -> Settings { let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - GPT_DIAGNOSTICS_INTEGRATION_ID, - &json!({ "enabled": enabled }), - ) - .expect("should insert diagnostics config"); + settings.select_module("ad-tag", "ad-tag.google.diagnostics"); settings } @@ -467,14 +532,11 @@ mod tests { #[test] fn register_excludes_diagnostics_from_unified_and_deferred_bundles() { - let settings = settings(true); - let plan = std::sync::Arc::new( - crate::auction::compile_auction_plan(&settings).expect("should compile auction plan"), - ); - let registry = - IntegrationRegistry::with_plan(&settings, plan).expect("should build registry"); + let settings = settings(); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build registry"); - assert!(registry.integration_enabled(GPT_DIAGNOSTICS_INTEGRATION_ID)); + assert!(registry.integration_runs(GPT_DIAGNOSTICS_INTEGRATION_ID)); assert!( !registry .js_module_ids_immediate() @@ -485,6 +547,10 @@ mod tests { .js_module_ids_deferred() .contains(&GPT_DIAGNOSTICS_INTEGRATION_ID) ); + assert!( + registry.js_part(GPT_DIAGNOSTICS_INTEGRATION_ID).is_some(), + "should serve the diagnostics module standalone" + ); } #[test] @@ -494,7 +560,7 @@ mod tests { Some("other=value; __Host-ts-console=1"), ); - let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let decision = prepare_request(&settings(), &mut request).expect("should prepare"); assert!(decision.active()); assert_eq!( @@ -518,7 +584,7 @@ mod tests { .headers_mut() .insert("purpose", HeaderValue::from_static("prefetch")); - let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let decision = prepare_request(&settings(), &mut request).expect("should prepare"); assert!( !decision.active(), @@ -534,7 +600,7 @@ mod tests { "https://publisher.example/page", Some("__Host-ts-console=1; other=value"), ); - let decision = prepare_request(&settings(true), &mut active).expect("should prepare"); + let decision = prepare_request(&settings(), &mut active).expect("should prepare"); assert!(decision.active()); assert_eq!(active.headers()[header::COOKIE], "other=value"); @@ -542,7 +608,7 @@ mod tests { "https://publisher.example/page", Some("__Host-ts-console=1; __Host-ts-console=1; other=value"), ); - let decision = prepare_request(&settings(true), &mut duplicate).expect("should prepare"); + let decision = prepare_request(&settings(), &mut duplicate).expect("should prepare"); assert!(!decision.active()); assert_eq!(duplicate.headers()[header::COOKIE], "other=value"); } @@ -558,7 +624,7 @@ mod tests { &format!("https://publisher.example/page?{query}&keep=1"), Some("__Host-ts-console=1"), ); - let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let decision = prepare_request(&settings(), &mut request).expect("should prepare"); assert!(!decision.active(), "{query} should fail closed"); assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None); assert_eq!(request.uri().query(), Some("keep=1")); @@ -568,7 +634,7 @@ mod tests { "https://publisher.example/page?ts_console=false&keep=1", Some("__Host-ts-console=1"), ); - let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let decision = prepare_request(&settings(), &mut request).expect("should prepare"); assert!(!decision.active()); assert_eq!( decision.cookie_action, @@ -579,7 +645,7 @@ mod tests { #[test] fn finalization_sets_cookie_and_strips_shared_cache_headers() { let mut request = navigation("https://publisher.example/?ts_console=1", None); - let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let decision = prepare_request(&settings(), &mut request).expect("should prepare"); let mut response = Response::builder() .header(header::CACHE_CONTROL, "public, max-age=60") .header(header::ETAG, "\"origin\"") @@ -622,7 +688,7 @@ mod tests { // because the adapter's terminal guard keys on the marker, not on the stamp, and // the `Set-Cookie` privacy net never sees a response that sets no cookie. let mut request = navigation("https://publisher.example/", Some("__Host-ts-console=1")); - let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let decision = prepare_request(&settings(), &mut request).expect("should prepare"); assert!(decision.active(), "the session cookie should activate"); assert_eq!( decision.cookie_action, @@ -642,7 +708,7 @@ mod tests { assert!( response .extensions() - .get::() + .get::() .is_some(), "should mark request-scoped diagnostics HTML for terminal re-enforcement" ); @@ -651,7 +717,7 @@ mod tests { #[test] fn an_inactive_decision_leaves_the_origin_cache_policy_alone() { let mut request = navigation("https://publisher.example/", None); - let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let decision = prepare_request(&settings(), &mut request).expect("should prepare"); assert!(!decision.requires_private_no_store()); let mut response = Response::builder() .header(header::CACHE_CONTROL, "public, max-age=60") @@ -669,7 +735,7 @@ mod tests { assert!( response .extensions() - .get::() + .get::() .is_none(), "should not mark an untouched response terminal-private" ); @@ -679,17 +745,186 @@ mod tests { fn config_rejects_unknown_fields() { let mut settings = create_test_settings(); settings - .integrations - .insert_config( - GPT_DIAGNOSTICS_INTEGRATION_ID, - &json!({ "enabled": true, "typo": true }), + .insert_module_config( + "ad-tag", + "ad-tag.google.diagnostics", + &json!({"typo": true }), ) .expect("should insert diagnostics config"); let error = settings - .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID) + .module_config::(MODULE) .expect_err("should reject unknown diagnostics config fields"); let error_text = format!("{error:?}"); assert!(error_text.contains("typo") || error_text.contains("unknown field")); } + + #[test] + fn module_constant_names_the_crate_folder_and_the_module_within_it() { + assert_eq!( + MODULE, + format!("{}.diagnostics", trusted_server_core::module_name!()), + "should be named by the folder this crate lives in" + ); + } + + #[test] + fn only_a_decision_that_acts_is_left_for_the_page_path() { + let mut ordinary = navigation("https://publisher.example/page", None); + prepare_request(&settings(), &mut ordinary).expect("should prepare"); + assert!( + IntegrationRequestState::of(&ordinary).is_empty(), + "an ordinary request should stay on the shared template path" + ); + + let mut activating = navigation("https://publisher.example/page?ts_console=1", None); + let decision = prepare_request(&settings(), &mut activating).expect("should prepare"); + assert_eq!( + IntegrationRequestState::of(&activating) + .get::(GPT_DIAGNOSTICS_INTEGRATION_ID) + .as_deref(), + Some(&decision), + "should leave the decision that acts for the page path" + ); + } + + #[test] + fn the_finalizer_acts_only_on_a_decision_left_for_the_page_path() { + let mut untouched = Response::builder() + .header(header::CACHE_CONTROL, "public, max-age=60") + .body(EdgeBody::empty()) + .expect("should build response"); + finalize_response_hook(&IntegrationRequestState::default(), &mut untouched); + assert_eq!( + untouched.headers()[header::CACHE_CONTROL], + "public, max-age=60", + "should leave a response alone when nothing was left for it" + ); + assert!( + !untouched.headers().contains_key(header::SET_COOKIE), + "should set no cookie when nothing was left for it" + ); + + let mut request = navigation("https://publisher.example/?ts_console=1", None); + prepare_request(&settings(), &mut request).expect("should prepare"); + let mut response = Response::builder() + .header(header::CACHE_CONTROL, "public, max-age=60") + .body(EdgeBody::empty()) + .expect("should build response"); + finalize_response_hook(&IntegrationRequestState::of(&request), &mut response); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should make the activating response private" + ); + assert_eq!( + response.headers()[header::SET_COOKIE], + SET_CONSOLE_COOKIE, + "should establish the session" + ); + } + + #[test] + fn an_active_document_loads_the_module_after_the_unified_bundle_once() { + use trusted_server_core::html_processor::{HtmlProcessorConfig, create_html_processor}; + use trusted_server_core::streaming_processor::StreamProcessor as _; + + let settings = settings(); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build registry"); + let mut request = navigation("https://publisher.example/page?ts_console=1", None); + let decision = prepare_request(&settings, &mut request).expect("should prepare"); + assert!( + decision.active(), + "should activate diagnostics for the query" + ); + let config = HtmlProcessorConfig::from_settings( + &settings, + ®istry, + "origin.example.com", + "publisher.example", + "https", + ) + .with_request_state(IntegrationRequestState::of(&request)); + let mut processor = create_html_processor(config); + + let output = processor + .process_chunk( + b"Test", + true, + ) + .expect("should process HTML"); + let processed = String::from_utf8(output).expect("should produce valid UTF-8"); + let bootstrap_marker = "__tsjs_gpt_diagnostics_active"; + let bundle_marker = "id=\"trustedserver-js\""; + let diagnostics_marker = "tsjs-gpt_diagnostics.min.js"; + + assert_eq!( + processed.matches(bootstrap_marker).count(), + 1, + "should inject the diagnostics bootstrap once" + ); + assert_eq!( + processed.matches(bundle_marker).count(), + 1, + "should inject the immediate TSJS bundle once" + ); + assert_eq!( + processed.matches(diagnostics_marker).count(), + 1, + "should inject one standalone diagnostics module" + ); + let bootstrap_index = processed + .find(bootstrap_marker) + .expect("should include diagnostics bootstrap"); + let bundle_index = processed + .find(bundle_marker) + .expect("should include immediate TSJS bundle"); + let diagnostics_index = processed + .find(diagnostics_marker) + .expect("should include standalone diagnostics module"); + assert!( + bootstrap_index < bundle_index, + "should activate before core executes" + ); + assert!( + bundle_index < diagnostics_index, + "should load diagnostics after core" + ); + } + + #[test] + fn an_inactive_document_gets_neither_script() { + use trusted_server_core::html_processor::{HtmlProcessorConfig, create_html_processor}; + use trusted_server_core::streaming_processor::StreamProcessor as _; + + let settings = settings(); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build registry"); + let mut request = navigation("https://publisher.example/page", None); + prepare_request(&settings, &mut request).expect("should prepare"); + let config = HtmlProcessorConfig::from_settings( + &settings, + ®istry, + "origin.example.com", + "publisher.example", + "https", + ) + .with_request_state(IntegrationRequestState::of(&request)); + let mut processor = create_html_processor(config); + + let output = processor + .process_chunk( + b"Test", + true, + ) + .expect("should process HTML"); + let processed = String::from_utf8(output).expect("should produce valid UTF-8"); + + assert!( + !processed.contains("__tsjs_gpt_diagnostics_active") + && !processed.contains("tsjs-gpt_diagnostics.min.js"), + "should write nothing into a document diagnostics is not active for: {processed}" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/ad-tag/google/src/gpt_bootstrap.js similarity index 100% rename from crates/trusted-server-core/src/integrations/gpt_bootstrap.js rename to crates/ad-tag/google/src/gpt_bootstrap.js diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/ad-tag/google/src/lib.rs similarity index 94% rename from crates/trusted-server-core/src/integrations/gpt.rs rename to crates/ad-tag/google/src/lib.rs index 3292cc5b3a..9d29c6b780 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -31,6 +31,23 @@ //! `") + fn try_from(rule: &BidParamOverrideRule) -> Result { + Self::new( + rule.when.bidder.as_deref(), + rule.when.zone.as_deref(), + &rule.set, + "auction.prebid.bid_param_override_rules[*]", + true, + ) + } } -#[cfg(test)] -fn build( - settings: &Settings, -) -> Result>, Report> { - let Some(config) = load_config(settings)? else { - return Ok(None); - }; - - validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?; +// Trims leading/trailing whitespace from config-side matcher strings so that +// accidental padding in TOML/JSON does not produce a silently non-matching rule. +// Runtime facts (bidder name, zone) are not trimmed — they come from controlled +// internal sources (bidder-map keys, trustedServer.zone) where whitespace is +// never expected. Matching is exact and case-sensitive after this normalisation. +fn validate_override_matcher_string( + value: &str, + field: &str, + source: &str, +) -> Result> { + let trimmed = value.trim(); - // Warn about bidders that appear in both lists — this is likely a config - // mistake. A bidder should be in either `bidders` (server-side) or - // `client_side_bidders` (browser-side), not both. - for bidder in &config.client_side_bidders { - if config.bidders.iter().any(|b| b == bidder) { - log::warn!( - "prebid: bidder \"{}\" is in both bidders and client_side_bidders — \ - it will run server-side AND be left for client-side, which is likely unintended", - bidder - ); - } + if trimmed.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{source}.{field} must not be empty"), + })); } - Ok(Some(PrebidIntegration::try_new(config)?)) + Ok(trimmed.to_string()) } -/// Register the Prebid integration when enabled. -/// -/// # Errors -/// -/// Returns an error when the Prebid integration is enabled with invalid -/// configuration. -pub fn register_for_plan( - settings: &Settings, - plan: &AuctionPlan, -) -> Result, Report> { - let Some(mut config) = - settings.integration_config::(PREBID_INTEGRATION_ID)? - else { - return Ok(None); - }; - let mut canonical = Vec::with_capacity(config.excluded_gam_ad_unit_path_suffixes.len()); - for suffix in std::mem::take(&mut config.excluded_gam_ad_unit_path_suffixes) { - if !canonical.contains(&suffix) { - canonical.push(suffix); - } +fn non_empty_override_object( + value: &serde_json::Map, + source: &str, + is_canonical: bool, +) -> Result, Report> { + if value.is_empty() { + let message = if is_canonical { + format!("{source}.set must not be empty") + } else { + format!("{source} entry must not be empty") + }; + return Err(Report::new(TrustedServerError::Configuration { message })); } - config.excluded_gam_ad_unit_path_suffixes = canonical; - validate_browser_config_for_startup(&config, &settings.proxy.allowed_domains)?; - validate_browser_bidder_ownership(&config, plan)?; - let integration = PrebidIntegration::for_browser_plan(&config, plan); - Ok(Some( - IntegrationRegistration::builder(PREBID_INTEGRATION_ID) - .with_proxy(integration.clone()) - .with_attribute_rewriter(integration.clone()) - .with_head_injector(integration) - .with_deferred_js() - .build(), - )) + + Ok(value.clone()) } +/// Copies browser headers to the outgoing Prebid Server request. +/// +/// The inbound `X-Forwarded-For` header is never copied: on the server-side +/// publisher auction path `from` is the browser navigation request, so its +/// XFF value is client-supplied and spoofable. Instead the header is +/// synthesized from `client_ip` — the platform-attested client address — +/// matching the trusted IP already sent in `OpenRTB` `device.ip`. +/// +/// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are +/// stripped from the `Cookie` header since consent travels exclusively +/// through the `OpenRTB` body. #[cfg(test)] -#[allow(clippy::missing_errors_doc)] -pub fn register( - settings: &Settings, -) -> Result, Report> { - let Some(integration) = build(settings)? else { - return Ok(None); - }; - - Ok(Some( - IntegrationRegistration::builder(PREBID_INTEGRATION_ID) - .with_proxy(integration.clone()) - .with_attribute_rewriter(integration.clone()) - .with_head_injector(integration) - .with_deferred_js() - .build(), - )) +fn copy_request_headers( + from: &http::Request, + to: &mut http::Request, + consent_forwarding: ConsentForwardingMode, + client_ip: Option, +) { + apply_prebid_header_values( + from.headers().get(header::COOKIE), + from.headers().get(header::USER_AGENT), + from.headers().get(header::REFERER), + from.headers().get(header::ACCEPT_LANGUAGE), + to.headers_mut(), + consent_forwarding, + client_ip, + ); } -#[async_trait(?Send)] -impl IntegrationProxy for PrebidIntegration { - fn integration_name(&self) -> &'static str { - PREBID_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - let mut routes = vec![]; - - routes.push(self.get("/bundle.js")); +/// Apply the common raw-header transport policy to a planned PBS request's +/// outbound headers. +pub(crate) fn apply_prebid_transport_headers( + from: &TransportHeaders, + to: &mut http::HeaderMap, + consent_forwarding: ConsentForwardingMode, + client_ip: Option, +) { + apply_prebid_header_values( + from.cookie(), + from.user_agent(), + from.referer(), + from.accept_language(), + to, + consent_forwarding, + client_ip, + ); +} - // Register routes for script removal patterns - // Patterns can be exact paths (e.g., "/prebid.min.js") or use matchit wildcards - // (e.g., "/static/prebid/{*rest}") - for pattern in &self.config.script_patterns { - // Intentional leak: runs once at startup and patterns are small. - // `IntegrationEndpoint` requires `&'static str`. - let static_path: &'static str = Box::leak(pattern.clone().into_boxed_str()); - routes.push(IntegrationEndpoint::get(static_path)); +#[allow( + clippy::too_many_arguments, + reason = "the helper preserves four independently optional raw headers plus transport policy" +)] +fn apply_prebid_header_values( + cookie: Option<&HeaderValue>, + user_agent: Option<&HeaderValue>, + referer: Option<&HeaderValue>, + accept_language: Option<&HeaderValue>, + to: &mut http::HeaderMap, + consent_forwarding: ConsentForwardingMode, + client_ip: Option, +) { + for (name, value) in [ + (header::USER_AGENT, user_agent), + (header::REFERER, referer), + (header::ACCEPT_LANGUAGE, accept_language), + ] { + if let Some(value) = value { + to.insert(name, value.clone()); } + } - routes + if let Some(ip) = client_ip + && let Ok(value) = HeaderValue::from_str(&ip.to_string()) + { + to.insert(header::HeaderName::from_static("x-forwarded-for"), value); } - async fn handle( - &self, - settings: &Settings, - services: &RuntimeServices, - req: http::Request, - ) -> Result, Report> { - let path = req.uri().path().to_string(); - let method = req.method().clone(); - - match method { - Method::GET if self.is_managed_external() && path == PREBID_BUNDLE_ROUTE => { - self.handle_external_bundle(settings, services, req).await + let Some(cookie_value) = cookie else { + return; + }; + if !consent_forwarding.strips_consent_cookies() { + to.insert(header::COOKIE, cookie_value.clone()); + return; + } + match cookie_value.to_str() { + Ok(value) => { + let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); + if !stripped.is_empty() + && let Ok(cookie_header) = HeaderValue::from_str(&stripped) + { + to.insert(header::COOKIE, cookie_header); } - // Serve empty JS for matching script patterns - Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), - _ => http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body(EdgeBody::from("Not Found")) - .change_context(TrustedServerError::Prebid { - message: "Failed to build Prebid not found response".to_string(), - }), + } + Err(_) => { + to.insert(header::COOKIE, cookie_value.clone()); } } } -impl IntegrationAttributeRewriter for PrebidIntegration { - fn integration_id(&self) -> &'static str { - PREBID_INTEGRATION_ID - } - - fn handles_attribute(&self, attribute: &str) -> bool { - matches!(attribute, "src" | "href") +/// Appends query parameters to a URL, handling both URLs with and without existing query strings. +/// Returns the original URL unchanged if params are empty or already present. +#[cfg(test)] +fn append_query_params(url: &str, params: &str) -> String { + if params.is_empty() || url.contains(params) { + return url.to_string(); } - - fn rewrite( - &self, - _attr_name: &str, - attr_value: &str, - _ctx: &IntegrationAttributeContext<'_>, - ) -> AttributeRewriteAction { - if self.matches_script_url(attr_value) { - AttributeRewriteAction::remove_element() - } else { - AttributeRewriteAction::keep() - } + if url.contains('?') { + format!("{}&{}", url, params) + } else { + format!("{}?{}", url, params) } } -fn serialize_injected_prebid_config(payload: &impl Serialize) -> String { - // JSON appears in script raw-text, where every less-than sign must be escaped. - serde_json::to_string(payload) - .unwrap_or_else(|error| { - log::warn!("Prebid: failed to serialize client config: {error}"); - "{}".to_string() - }) - .replace('<', "\\u003c") -} - -fn injected_prebid_config_script(config_json: &str) -> String { - format!( - r#""# +/// Parse a planned PBS response with the configured profile behavior. +/// +/// This preserves the legacy PBS status, body, bid, cache, and metadata +/// semantics while allowing each planned provider to retain its own identity. +pub(crate) async fn parse_planned_prebid_response( + provider_id: &str, + demand: &PrebidServerDemand, + input: &ProviderAuctionInput, + response: PlatformResponse, + response_time_ms: u64, + auction_id: &str, +) -> Result> { + let response = response.response; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "prebid", ) -} - -impl IntegrationHeadInjector for PrebidIntegration { - fn integration_id(&self) -> &'static str { - PREBID_INTEGRATION_ID - } + .await + .change_context(TrustedServerError::Integration { + integration: MODULE.to_string(), + message: "Failed to read Prebid response body".to_string(), + }); - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - if let Some(inserts) = &self.planned_head_inserts { - return inserts.clone(); - } - let payload = InjectedPrebidClientConfig { - bidders: Some({ - #[cfg(test)] - { - self.legacy_config - .as_ref() - .map_or(&[][..], |config| config.bidders.as_slice()) - } - #[cfg(not(test))] - { - &[] - } - }), - ..InjectedPrebidClientConfig::from(&self.config) + if !status.is_success() { + log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); + let body_bytes = match body_bytes { + Ok(body_bytes) => Some(body_bytes), + Err(error) => { + log::warn!( + "Prebid auction {auction_id:?} failed to read non-success response body: {error:?}" + ); + None + } }; + if demand.debug + && let Some(body_bytes) = body_bytes.as_deref() + { + match prebid_body_preview(body_bytes) { + Some(preview) => { + let truncation = if preview.truncated { + " (truncated)" + } else { + "" + }; + log::warn!( + "Prebid auction {auction_id:?} error response body preview{truncation}: {}", + preview.text + ); + } + None => log::warn!( + "Prebid auction {auction_id:?} returned an empty error response body" + ), + } + } - let config_json = serialize_injected_prebid_config(&payload); - let mut inserts = vec![injected_prebid_config_script(&config_json)]; - - inserts.push(self.external_bundle_script_tag()); - - inserts + let status_code = status.as_u16(); + let mut parsed = AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) + .with_metadata("http_status", serde_json::json!(status_code)) + .with_metadata( + "message", + serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), + ); + if demand.debug + && let Some(message) = body_bytes + .as_deref() + .and_then(|body| extract_prebid_error_message(body, content_type.as_deref())) + { + parsed.metadata.insert( + "upstream_message".to_string(), + serde_json::json!(message.text), + ); + parsed.metadata.insert( + "upstream_message_truncated".to_string(), + serde_json::json!(message.truncated), + ); + } + return Ok(parsed); } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct InjectedManagedUserIdStorage<'a> { - #[serde(rename = "type")] - storage_type: PrebidUserIdStorageType, - name: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - expires: Option, - #[serde(skip_serializing_if = "Option::is_none")] - refresh_in_seconds: Option, -} -impl<'a> From<&'a PrebidManagedUserIdStorage> for InjectedManagedUserIdStorage<'a> { - fn from(storage: &'a PrebidManagedUserIdStorage) -> Self { - Self { - storage_type: storage.storage_type, - name: &storage.name, - expires: storage.expires, - refresh_in_seconds: storage.refresh_in_seconds, + let body_bytes = body_bytes?; + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Integration { + integration: MODULE.to_string(), + message: "Failed to parse Prebid response".to_string(), + })?; + if demand.debug && log::log_enabled!(log::Level::Trace) { + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), + Err(error) => log::warn!("Prebid: failed to serialize response for logging: {error}"), } } -} -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct InjectedManagedUserId<'a> { - name: &'a str, - #[serde(skip_serializing_if = "serde_json::Map::is_empty")] - params: &'a serde_json::Map, - #[serde(skip_serializing_if = "Option::is_none")] - storage: Option>, + let mut parsed = + parse_planned_prebid_openrtb(provider_id, input, &response_json, response_time_ms); + enrich_planned_prebid_metadata(demand, &response_json, &mut parsed); + log::info!( + "Prebid provider {provider_id} returned {} bids in {}ms", + parsed.bids.len(), + response_time_ms + ); + Ok(parsed) } -impl<'a> From<&'a PrebidManagedUserIdConfig> for InjectedManagedUserId<'a> { - fn from(config: &'a PrebidManagedUserIdConfig) -> Self { - Self { - name: &config.name, - params: &config.params, - storage: config - .storage - .as_ref() - .map(InjectedManagedUserIdStorage::from), +fn parse_planned_prebid_openrtb( + provider_id: &str, + input: &ProviderAuctionInput, + response_json: &Json, + response_time_ms: u64, +) -> AuctionResponse { + let Some(response) = response_json.as_object() else { + return AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", serde_json::json!("parse_response")); + }; + match response.get("cur") { + None => {} + Some(Json::String(currency)) if currency.eq_ignore_ascii_case(DEFAULT_CURRENCY) => {} + Some(Json::String(currency)) => { + return AuctionResponse::no_bid(provider_id, response_time_ms) + .with_metadata("unsupported_currency", serde_json::json!(currency)); + } + Some(_) => { + return AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", serde_json::json!("parse_response")); } } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct InjectedPrebidClientConfig<'a> { - account_id: &'a str, - #[serde(skip_serializing_if = "Vec::is_empty")] - managed_user_ids: Vec>, - timeout: u32, - debug: bool, - #[serde(skip_serializing_if = "Option::is_none")] - bidders: Option<&'a [String]>, - #[serde(skip_serializing_if = "Option::is_none")] - server_side_bidders: Option>, - #[serde(skip_serializing_if = "<[String]>::is_empty")] - client_side_bidders: &'a [String], - #[serde(skip_serializing_if = "<[String]>::is_empty")] - excluded_gam_ad_unit_path_suffixes: &'a [String], -} -impl<'a> From<&'a PrebidIntegrationConfig> for InjectedPrebidClientConfig<'a> { - fn from(config: &'a PrebidIntegrationConfig) -> Self { - Self { - account_id: config.account_id.as_deref().unwrap_or_default(), - managed_user_ids: config - .managed_user_ids - .iter() - .map(InjectedManagedUserId::from) - .collect(), - timeout: config.timeout_ms, - debug: config.debug, - bidders: None, - server_side_bidders: None, - client_side_bidders: &config.client_side_bidders, - excluded_gam_ad_unit_path_suffixes: &config.excluded_gam_ad_unit_path_suffixes, + let dimensions_by_slot = build_bid_dimension_index(input); + let mut diagnostics = ResponseAdmissionDiagnostics::default(); + let mut bids = Vec::new(); + if let Some(seatbids) = response_json.get("seatbid").and_then(Json::as_array) { + for seatbid in seatbids { + let returned_seat = seatbid + .get("seat") + .and_then(Json::as_str) + .filter(|seat| !seat.is_empty()); + let delivery_bidder = returned_seat.unwrap_or("unknown"); + if let Some(entries) = seatbid.get("bid").and_then(Json::as_array) { + for entry in entries { + match parse_planned_prebid_bid( + entry, + delivery_bidder, + returned_seat, + &dimensions_by_slot, + ) { + Ok(bid) => bids.push(bid), + Err(reason) => { + diagnostics.record(reason); + if reason == BidRejectionReason::InvalidBid { + let impression = entry + .get("impid") + .and_then(Json::as_str) + .unwrap_or(""); + log::warn!( + "Prebid: failed to parse bid from seat '{delivery_bidder}' for imp '{impression}'" + ); + } + } + } + } + } } } + let mut parsed = if bids.is_empty() { + AuctionResponse::no_bid(provider_id, response_time_ms) + } else { + AuctionResponse::success(provider_id, bids, response_time_ms) + }; + diagnostics.attach_to(&mut parsed); + parsed } -/// Returns `true` when `params` is not a usable PBS bidder-params object — an -/// empty object `{}` or any non-object value such as `null`. -/// -/// PBS rejects an imp whose `bidder.` carries such a value, and it cannot -/// tell a fabricated empty from an explicitly supplied one — they are identical -/// bytes on the wire. The merge uses this to stop an unusable value from -/// clobbering real params, and the final pass uses it to drop whatever remains. -#[cfg(test)] -fn is_unusable_bidder_params(params: &Json) -> bool { - // `None` covers non-object values (e.g. `null`); an empty map covers `{}`. - params.as_object().is_none_or(serde_json::Map::is_empty) +fn enrich_planned_prebid_metadata( + demand: &PrebidServerDemand, + response_json: &Json, + parsed: &mut AuctionResponse, +) { + let ext = response_json.get("ext"); + for key in ["responsetimemillis", "errors", "warnings"] { + if let Some(value) = ext.and_then(|ext| ext.get(key)) { + parsed.metadata.insert(key.to_string(), value.clone()); + } + } + if demand.debug { + if let Some(value) = ext.and_then(|ext| ext.get("debug")) { + parsed.metadata.insert("debug".to_string(), value.clone()); + } + if let Some(value) = ext + .and_then(|ext| ext.get("prebid")) + .and_then(|prebid| prebid.get("bidstatus")) + { + parsed + .metadata + .insert("bidstatus".to_string(), value.clone()); + } + } } -#[cfg(test)] -fn expand_trusted_server_bidders( - configured_bidders: &[String], - params: &Json, -) -> HashMap { - let per_bidder = params.get(BIDDER_PARAMS_KEY).and_then(Json::as_object); - - if configured_bidders.is_empty() { - return per_bidder - .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default(); - } - - configured_bidders - .iter() - .map(|bidder| { - let value = per_bidder - .and_then(|m| m.get(bidder).cloned()) - .unwrap_or_else(|| { - // No per-bidder map → use entire params as shared config - if per_bidder.is_some() { - Json::Object(Default::default()) - } else { - params.clone() - } - }); - (bidder.clone(), value) +fn parse_planned_prebid_bid( + bid: &Json, + delivery_bidder: &str, + returned_seat: Option<&str>, + dimensions_by_slot: &BidDimensionIndex, +) -> Result { + let slot_id = bid + .get("impid") + .and_then(Json::as_str) + .filter(|slot_id| !slot_id.is_empty()) + .ok_or(BidRejectionReason::InvalidBid)? + .to_string(); + let width = parse_optional_bid_dimension(bid, "w")?; + let height = parse_optional_bid_dimension(bid, "h")?; + let (width, height) = resolve_bid_dimensions(dimensions_by_slot, &slot_id, width, height)?; + let price = bid + .get("price") + .and_then(Json::as_f64) + .filter(|price| price.is_finite() && *price >= 0.0) + .ok_or(BidRejectionReason::InvalidBid)?; + let creative = bid.get("adm").and_then(Json::as_str).map(String::from); + let cache_entry = bid + .get("ext") + .and_then(|ext| ext.get("prebid")) + .and_then(|prebid| prebid.get("cache")) + .and_then(|cache| cache.get("bids")); + let cache_id = cache_entry + .and_then(|cache| cache.get("cacheId")) + .and_then(Json::as_str) + .map(String::from); + let (cache_host, cache_path) = cache_entry + .and_then(|cache| cache.get("url")) + .and_then(Json::as_str) + .and_then(|value| { + ParsedUrl::parse(value) + .map_err(|error| log::debug!("PBS cache URL parse failed: {error}")) + .ok() }) - .collect() -} - -/// Shallow-merges `override_obj` into `params`. -/// -/// When `params` is a JSON object, each top-level key in `override_obj` is -/// inserted or replaced in `params`. Nested objects are replaced wholesale — -/// they are not recursed into. When `params` is not an object, it is left -/// untouched to preserve pre-existing behavior. -/// -/// Returns whether `params` was an object and the merge path ran. -fn merge_bidder_param_object( - params: &mut Json, - override_obj: &serde_json::Map, -) -> bool { - if let Json::Object(base) = params { - for (k, v) in override_obj { - base.insert(k.clone(), v.clone()); - } - - true - } else { - false + .map(|url| { + let host = url.host_str().map(String::from); + let path = url.path().to_string(); + let path = (!path.is_empty() && path != "/").then_some(path); + (host, path) + }) + .unwrap_or((None, None)); + if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — creative will fail to render for slot '{slot_id}'" + ); } + + Ok(AuctionBid { + slot_id, + price: Some(price), + currency: DEFAULT_CURRENCY.to_string(), + creative, + adomain: bid.get("adomain").and_then(Json::as_array).map(|domains| { + domains + .iter() + .filter_map(Json::as_str) + .map(String::from) + .collect() + }), + bidder: delivery_bidder.to_string(), + returned_seat: returned_seat.map(str::to_string), + width, + height, + nurl: bid.get("nurl").and_then(Json::as_str).map(String::from), + burl: bid.get("burl").and_then(Json::as_str).map(String::from), + bid_id: bid + .get("id") + .and_then(Json::as_str) + .filter(|value| !value.is_empty()) + .map(String::from), + ad_id: bid.get("adid").and_then(Json::as_str).map(String::from), + creative_id: bid.get("crid").and_then(Json::as_str).map(String::from), + renderer: None, + cache_id, + cache_host, + cache_path, + metadata: HashMap::new(), + }) } // ============================================================================ -// Generic bid-parameter override engine +// Prebid Auction Provider // ============================================================================ +/// The request extension [`PrebidAuctionProvider`] writes. #[cfg(test)] -fn warn_unconfigured_bidder(config: &LegacyPrebidServerConfig, bidder: &str, field: &str) { - if !config.bidders.iter().any(|b| b == bidder) { - if config.client_side_bidders.iter().any(|b| b == bidder) { - log::warn!( - "prebid: {field} entry targets client-side-only bidder \ - '{bidder}' — server-side override will never apply" - ); - } else { - log::warn!( - "prebid: {field} entry references unconfigured bidder \ - '{bidder}' — rule will never fire" - ); - } - } +#[derive(Debug, Serialize)] +struct RequestExt { + #[serde(skip_serializing_if = "Option::is_none")] + prebid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + trusted_server: Option, } -#[derive(Debug, Default, Clone)] -pub(crate) struct BidParamOverrideEngine { - rules: Vec, - // Maps bidder name to the indices (into `rules`) of rules that constrain on that bidder. - // Rules with no bidder constraint (zone-only or catch-all) are kept in `wildcard_indices`. - // Both slices are in declaration order; `apply` merges them without allocation. - bidder_index: HashMap>, - wildcard_indices: Vec, -} +#[cfg(test)] +impl ToExt for RequestExt {} -#[derive(Debug, Clone, PartialEq, Eq)] -struct CompiledBidParamOverrideRule { - bidder: Option, - zone: Option, - set: serde_json::Map, +/// The `ext.prebid` object of a request. +#[cfg(test)] +#[derive(Debug, Serialize, Deserialize)] +struct PrebidExt { + #[serde(skip_serializing_if = "Option::is_none")] + debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + returnallbidstatus: Option, } -#[derive(Debug, Copy, Clone)] -struct BidParamOverrideFacts<'a> { - bidder: &'a str, - zone: Option<&'a str>, +/// The impression extension [`PrebidAuctionProvider`] writes. +#[cfg(test)] +#[derive(Debug, Serialize)] +struct ImpExt { + prebid: PrebidImpExt, } -impl BidParamOverrideEngine { - #[cfg(test)] - fn try_from_config( - config: &LegacyPrebidServerConfig, - ) -> Result> { - let mut rules = Vec::new(); - - let mut bidder_overrides = config.bid_param_overrides.iter().collect::>(); - bidder_overrides.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); +#[cfg(test)] +impl ToExt for ImpExt {} - for (bidder, set) in bidder_overrides { - warn_unconfigured_bidder(config, bidder, "bid_param_overrides"); - rules.push(CompiledBidParamOverrideRule::from_bidder_override( - bidder.as_str(), - set, - )?); - } +/// The `imp.ext.prebid` object: inline bidder parameters, or a stored +/// request reference. +#[cfg(test)] +#[derive(Debug, Default, Serialize)] +struct PrebidImpExt { + #[serde(skip_serializing_if = "HashMap::is_empty")] + bidder: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + storedrequest: Option, +} - let mut zone_overrides = config.bid_param_zone_overrides.iter().collect::>(); - zone_overrides.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); +/// PBS imp-level stored request reference. +/// +/// PBS merges the stored imp JSON (keyed by `id`) into the outgoing request, +/// populating bidder params that are not sent inline. +#[cfg(test)] +#[derive(Debug, Serialize)] +struct ImpStoredRequest { + id: String, +} - for (bidder, zone_override_sets) in zone_overrides { - warn_unconfigured_bidder(config, bidder, "bid_param_zone_overrides"); - let mut sorted_zone_overrides = zone_override_sets.iter().collect::>(); - sorted_zone_overrides - .sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); +/// Legacy Prebid Server auction provider retained only for parity tests. +#[cfg(test)] +pub struct PrebidAuctionProvider { + config: LegacyPrebidServerConfig, + bid_param_override_engine: Arc, +} - for (zone, set) in sorted_zone_overrides { - rules.push(CompiledBidParamOverrideRule::from_zone_override( - bidder.as_str(), - zone.as_str(), - set, - )?); - } - } +#[cfg(test)] +#[derive(Default)] +struct PrebidImpressionDisposition { + aps_only: usize, + invalid: usize, +} - for rule in &config.bid_param_override_rules { - if let Some(bidder) = &rule.when.bidder { - warn_unconfigured_bidder(config, bidder, "bid_param_override_rules"); - } - rules.push(CompiledBidParamOverrideRule::try_from(rule)?); - } +#[cfg(test)] +struct PrebidRequestBuild { + request: OpenRtbRequest, + disposition: PrebidImpressionDisposition, +} - let mut bidder_index: HashMap> = HashMap::new(); - let mut wildcard_indices: Vec = Vec::new(); - for (idx, rule) in rules.iter().enumerate() { - match &rule.bidder { - Some(bidder) => bidder_index.entry(bidder.clone()).or_default().push(idx), - None => wildcard_indices.push(idx), - } - } +#[cfg(test)] +impl PrebidAuctionProvider { + #[cfg(test)] + fn new(config: LegacyPrebidServerConfig) -> Self { + Self::try_new(config).expect("should compile prebid bid param overrides") + } + /// Create a new Prebid auction provider with validated override rules. + /// + /// # Errors + /// + /// Returns an error when the configured bidder-param override rules are invalid. + pub fn try_new(config: LegacyPrebidServerConfig) -> Result> { Ok(Self { - rules, - bidder_index, - wildcard_indices, + bid_param_override_engine: Arc::new(BidParamOverrideEngine::try_from_config(&config)?), + config, }) } - fn try_from_profile_config( - bid_param_zone_overrides: &std::collections::BTreeMap< - String, - std::collections::BTreeMap>, - >, - bid_param_overrides: &std::collections::BTreeMap>, - bid_param_override_rules: &[BidParamOverrideRule], - ) -> Result> { - let mut rules = Vec::new(); - for (bidder, set) in bid_param_overrides { - rules.push(CompiledBidParamOverrideRule::from_bidder_override( - bidder, set, - )?); - } - for (bidder, zone_override_sets) in bid_param_zone_overrides { - for (zone, set) in zone_override_sets { - rules.push(CompiledBidParamOverrideRule::from_zone_override( - bidder, zone, set, - )?); - } - } - for rule in bid_param_override_rules { - rules.push(CompiledBidParamOverrideRule::try_from(rule)?); - } - let mut bidder_index: HashMap> = HashMap::new(); - let mut wildcard_indices = Vec::new(); - for (index, rule) in rules.iter().enumerate() { - match &rule.bidder { - Some(bidder) => bidder_index.entry(bidder.clone()).or_default().push(index), - None => wildcard_indices.push(index), - } - } - Ok(Self { - rules, - bidder_index, - wildcard_indices, - }) - } - - fn apply(&self, facts: BidParamOverrideFacts<'_>, params: &mut Json) { - let bidder_indices = self.bidder_index.get(facts.bidder).map(Vec::as_slice); - for idx in merged_rule_indices(&self.wildcard_indices, bidder_indices) { - let rule = &self.rules[idx]; - if rule.matches(facts) { - if merge_bidder_param_object(params, &rule.set) { - log::debug!( - "prebid: applying bidder param override for bidder '{}' zone {:?}: keys {:?}", - facts.bidder, - facts.zone, - rule.set.keys().collect::>() - ); - } else { - log::debug!( - "prebid: skipping bidder param override for bidder '{}' zone {:?}: params is not a JSON object", - facts.bidder, - facts.zone - ); - } - } + /// Returns the full Prebid Server `OpenRTB2` auction endpoint URL. + /// + /// Backward-compatible normalization: `server_url` may be configured as + /// either the PBS origin (path is appended here) or the full endpoint + /// already ending in `/openrtb2/auction` (used as-is, ignoring a trailing + /// slash) — both shapes produce the same request URL. + fn auction_endpoint_url(&self) -> String { + let base = self.config.server_url.trim_end_matches('/'); + if base.ends_with("/openrtb2/auction") { + base.to_string() + } else { + format!("{base}/openrtb2/auction") } } - /// Apply compiled profile rules to already centrally routed bidder params. - pub(crate) fn apply_routed(&self, bidder: &str, zone: Option<&str>, params: &mut Json) { - self.apply(BidParamOverrideFacts { bidder, zone }, params); - } -} + /// Build an enriched `OpenRTB` request and retain impression drop provenance. + fn build_openrtb( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + signer: Option<(&RequestSigner, String, &SigningParams)>, + request_info: RequestInfo, + ) -> PrebidRequestBuild { + let mut disposition = PrebidImpressionDisposition::default(); + let imps = request + .slots + .iter() + .filter_map(|slot| { + let slot_context = format!("slot '{}'", slot.id); + let formats: Vec<_> = slot + .formats + .iter() + .filter(|f| f.media_type == MediaType::Banner) + .filter_map(|f| { + let width = to_openrtb_i32(f.width, "format.w", &slot_context); + let height = to_openrtb_i32(f.height, "format.h", &slot_context); -/// Validate and compile server-side Prebid profile override fields. -/// -/// This narrow hook shares the existing override compiler without coupling -/// auction-profile availability to the browser integration's enablement. -pub(crate) fn compile_profile_override_rules( - bid_param_zone_overrides: &std::collections::BTreeMap< - String, - std::collections::BTreeMap>, - >, - bid_param_overrides: &std::collections::BTreeMap>, - bid_param_override_rules: &[BidParamOverrideRule], -) -> Result> { - BidParamOverrideEngine::try_from_profile_config( - bid_param_zone_overrides, - bid_param_overrides, - bid_param_override_rules, - ) -} + match (width, height) { + (Some(width), Some(height)) => Some(Format { + w: Some(width), + h: Some(height), + ..Default::default() + }), + _ => None, + } + }) + .collect(); -fn merged_rule_indices<'a>( - wildcard_indices: &'a [usize], - bidder_indices: Option<&'a [usize]>, -) -> impl Iterator + 'a { - let mut wildcard = wildcard_indices.iter().copied().peekable(); - let mut bidder = bidder_indices.unwrap_or(&[]).iter().copied().peekable(); + if formats.is_empty() { + disposition.invalid += 1; + log::warn!( + "prebid: dropping imp '{}' — no valid banner formats after filtering", + slot.id + ); + return None; + } - std::iter::from_fn(move || match (wildcard.peek(), bidder.peek()) { - (Some(wildcard_idx), Some(bidder_idx)) if wildcard_idx <= bidder_idx => wildcard.next(), - (Some(_), Some(_)) => bidder.next(), - (Some(_), None) => wildcard.next(), - (None, Some(_)) => bidder.next(), - (None, None) => None, - }) -} + // Extract zone from trustedServer params (sent by the JS + // adapter from `mediaTypes.banner.name`, e.g. "header", "fixed_bottom"). + let zone: Option<&str> = slot + .bidders + .get(TRUSTED_SERVER_BIDDER) + .and_then(|p| p.get(ZONE_KEY)) + .and_then(Json::as_str); -impl CompiledBidParamOverrideRule { - fn from_bidder_override( - bidder: &str, - set: &serde_json::Map, - ) -> Result> { - Self::new( - Some(bidder), - None, - set, - &format!("integrations.prebid.bid_param_overrides.{bidder}"), - false, - ) - } + // Build the bidder map for PBS from two sources: + // 1. the `trustedServer` orchestrator entry, expanded into the + // configured PBS bidders (`expand_trusted_server_bidders`); + // 2. direct entries naming a configured PBS bidder. + // The JS adapter sends "trustedServer" as the bidder (our + // orchestrator adapter name); provider-specific keys like "aps" + // run their own auction and are skipped here. + // + // The two sources are collected separately so the merge below is + // deterministic regardless of `slot.bidders` HashMap iteration + // order: direct entries win, but an unusable direct value (`{}` or + // a non-object) must not clobber real params from the expansion. + let mut expanded: HashMap = HashMap::new(); + let mut direct: Vec<(String, Json)> = Vec::new(); + let mut excluded_aps = false; + for (name, params) in &slot.bidders { + if name.eq_ignore_ascii_case("aps") { + // APS is a separate OpenRTB provider. Never send native + // APS demand through PBS for the same cohort. + excluded_aps = true; + } else if name == TRUSTED_SERVER_BIDDER { + for (bidder, params) in + expand_trusted_server_bidders(&self.config.bidders, params) + { + if bidder.eq_ignore_ascii_case("aps") { + excluded_aps = true; + } else { + expanded.insert(bidder, params); + } + } + } else if self.config.bidders.iter().any(|bidder| bidder == name) { + direct.push((name.clone(), params.clone())); + } else { + // Any unrecognized key is likely a misconfiguration (a + // slot bidder absent from `config.bidders`) that silently + // yields an empty bidder map and a stored-request no-bid — + // log it so the drop is diagnosable. + log::debug!( + "prebid: dropping slot '{}' bidder '{}' — not in config.bidders and not a known provider key", + slot.id, + name + ); + } + } - fn from_zone_override( - bidder: &str, - zone: &str, - set: &serde_json::Map, - ) -> Result> { - Self::new( - Some(bidder), - Some(zone), - set, - &format!("integrations.prebid.bid_param_zone_overrides.{bidder}.{zone}"), - false, - ) - } + let mut bidder = expanded; + // Bidders whose params came from the slot itself rather than from + // `trustedServer` expansion. Only these can represent a publisher + // misconfiguration if they are dropped below; a dropped fabricated + // entry is the designed stored-request path. + let mut slot_supplied: HashSet = HashSet::new(); + for (name, params) in direct { + // Direct entries win over the expansion — except when an + // unusable direct value (`{}` or a non-object) would overwrite + // real params the expansion already supplied. PBS cannot tell a + // fabricated empty from an explicit one, so real params must not + // be discarded in favour of either. + let clobbers_real = is_unusable_bidder_params(¶ms) + && bidder + .get(&name) + .is_some_and(|existing| !is_unusable_bidder_params(existing)); + if !clobbers_real { + slot_supplied.insert(name.clone()); + bidder.insert(name, params); + } + } - fn new( - bidder: Option<&str>, - zone: Option<&str>, - set: &serde_json::Map, - source: &str, - is_canonical: bool, - ) -> Result> { - let bidder = bidder - .map(|value| validate_override_matcher_string(value, "when.bidder", source)) - .transpose()?; - let zone = zone - .map(|value| validate_override_matcher_string(value, "when.zone", source)) - .transpose()?; + // Apply canonical and compatibility-derived rules in normalized + // order. An override rule can populate a bidder that arrived with + // empty params, promoting a fabricated empty into a valid bidder. + for (name, params) in &mut bidder { + self.bid_param_override_engine + .apply(BidParamOverrideFacts { bidder: name, zone }, params); + } - if bidder.is_none() && zone.is_none() { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("{source} must include at least one matcher"), - })); - } - - Ok(Self { - bidder, - zone, - set: non_empty_override_object(set, source, is_canonical)?, - }) - } - - fn matches(&self, facts: BidParamOverrideFacts<'_>) -> bool { - if let Some(expected_bidder) = self.bidder.as_deref() - && expected_bidder != facts.bidder - { - return false; - } - - if let Some(expected_zone) = self.zone.as_deref() - && facts.zone != Some(expected_zone) - { - return false; - } - - true - } -} - -impl TryFrom for CompiledBidParamOverrideRule { - type Error = Report; - - fn try_from(rule: BidParamOverrideRule) -> Result { - Self::try_from(&rule) - } -} - -impl TryFrom<&BidParamOverrideRule> for CompiledBidParamOverrideRule { - type Error = Report; - - fn try_from(rule: &BidParamOverrideRule) -> Result { - Self::new( - rule.when.bidder.as_deref(), - rule.when.zone.as_deref(), - &rule.set, - "integrations.prebid.bid_param_override_rules[*]", - true, - ) - } -} - -// Trims leading/trailing whitespace from config-side matcher strings so that -// accidental padding in TOML/JSON does not produce a silently non-matching rule. -// Runtime facts (bidder name, zone) are not trimmed — they come from controlled -// internal sources (bidder-map keys, trustedServer.zone) where whitespace is -// never expected. Matching is exact and case-sensitive after this normalisation. -fn validate_override_matcher_string( - value: &str, - field: &str, - source: &str, -) -> Result> { - let trimmed = value.trim(); - - if trimmed.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("{source}.{field} must not be empty"), - })); - } - - Ok(trimmed.to_string()) -} - -fn non_empty_override_object( - value: &serde_json::Map, - source: &str, - is_canonical: bool, -) -> Result, Report> { - if value.is_empty() { - let message = if is_canonical { - format!("{source}.set must not be empty") - } else { - format!("{source} entry must not be empty") - }; - return Err(Report::new(TrustedServerError::Configuration { message })); - } - - Ok(value.clone()) -} - -/// Copies browser headers to the outgoing Prebid Server request. -/// -/// The inbound `X-Forwarded-For` header is never copied: on the server-side -/// publisher auction path `from` is the browser navigation request, so its -/// XFF value is client-supplied and spoofable. Instead the header is -/// synthesized from `client_ip` — the platform-attested client address — -/// matching the trusted IP already sent in `OpenRTB` `device.ip`. -/// -/// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are -/// stripped from the `Cookie` header since consent travels exclusively -/// through the `OpenRTB` body. -#[cfg(test)] -fn copy_request_headers( - from: &http::Request, - to: &mut http::Request, - consent_forwarding: ConsentForwardingMode, - client_ip: Option, -) { - apply_prebid_header_values( - from.headers().get(header::COOKIE), - from.headers().get(header::USER_AGENT), - from.headers().get(header::REFERER), - from.headers().get(header::ACCEPT_LANGUAGE), - to, - consent_forwarding, - client_ip, - ); -} - -/// Apply the common raw-header transport policy for a planned PBS request. -pub(crate) fn apply_prebid_transport_headers( - from: &PrebidTransportHeaders, - to: &mut http::Request, - consent_forwarding: ConsentForwardingMode, - client_ip: Option, -) { - apply_prebid_header_values( - from.cookie(), - from.user_agent(), - from.referer(), - from.accept_language(), - to, - consent_forwarding, - client_ip, - ); -} - -#[allow( - clippy::too_many_arguments, - reason = "the helper preserves four independently optional raw headers plus transport policy" -)] -fn apply_prebid_header_values( - cookie: Option<&HeaderValue>, - user_agent: Option<&HeaderValue>, - referer: Option<&HeaderValue>, - accept_language: Option<&HeaderValue>, - to: &mut http::Request, - consent_forwarding: ConsentForwardingMode, - client_ip: Option, -) { - for (name, value) in [ - (header::USER_AGENT, user_agent), - (header::REFERER, referer), - (header::ACCEPT_LANGUAGE, accept_language), - ] { - if let Some(value) = value { - to.headers_mut().insert(name, value.clone()); - } - } - - if let Some(ip) = client_ip - && let Ok(value) = HeaderValue::from_str(&ip.to_string()) - { - to.headers_mut() - .insert(header::HeaderName::from_static("x-forwarded-for"), value); - } - - let Some(cookie_value) = cookie else { - return; - }; - if !consent_forwarding.strips_consent_cookies() { - to.headers_mut() - .insert(header::COOKIE, cookie_value.clone()); - return; - } - match cookie_value.to_str() { - Ok(value) => { - let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); - if !stripped.is_empty() - && let Ok(cookie_header) = HeaderValue::from_str(&stripped) - { - to.headers_mut().insert(header::COOKIE, cookie_header); - } - } - Err(_) => { - to.headers_mut() - .insert(header::COOKIE, cookie_value.clone()); - } - } -} - -/// Appends query parameters to a URL, handling both URLs with and without existing query strings. -/// Returns the original URL unchanged if params are empty or already present. -#[cfg(test)] -fn append_query_params(url: &str, params: &str) -> String { - if params.is_empty() || url.contains(params) { - return url.to_string(); - } - if url.contains('?') { - format!("{}&{}", url, params) - } else { - format!("{}?{}", url, params) - } -} - -/// Parse a planned PBS response with the configured profile behavior. -/// -/// This preserves the legacy PBS status, body, bid, cache, and metadata -/// semantics while allowing each planned provider to retain its own identity. -pub(crate) async fn parse_planned_prebid_response( - provider_id: &str, - profile: &PrebidProfilePlan, - input: &ProviderAuctionInput, - response: PlatformResponse, - response_time_ms: u64, - auction_id: &str, -) -> Result> { - let response = response.response; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - let body_bytes = collect_response_bounded( - response.into_body(), - UPSTREAM_RTB_MAX_RESPONSE_BYTES, - "prebid", - ) - .await - .change_context(TrustedServerError::Prebid { - message: "Failed to read Prebid response body".to_string(), - }); - - if !status.is_success() { - log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); - let body_bytes = match body_bytes { - Ok(body_bytes) => Some(body_bytes), - Err(error) => { - log::warn!( - "Prebid auction {auction_id:?} failed to read non-success response body: {error:?}" - ); - None - } - }; - if profile.debug - && let Some(body_bytes) = body_bytes.as_deref() - { - match prebid_body_preview(body_bytes) { - Some(preview) => { - let truncation = if preview.truncated { - " (truncated)" - } else { - "" - }; - log::warn!( - "Prebid auction {auction_id:?} error response body preview{truncation}: {}", - preview.text - ); - } - None => log::warn!( - "Prebid auction {auction_id:?} returned an empty error response body" - ), - } - } - - let status_code = status.as_u16(); - let mut parsed = AuctionResponse::error(provider_id, response_time_ms) - .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) - .with_metadata("http_status", serde_json::json!(status_code)) - .with_metadata( - "message", - serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), - ); - if profile.debug - && let Some(message) = body_bytes - .as_deref() - .and_then(|body| extract_prebid_error_message(body, content_type.as_deref())) - { - parsed.metadata.insert( - "upstream_message".to_string(), - serde_json::json!(message.text), - ); - parsed.metadata.insert( - "upstream_message_truncated".to_string(), - serde_json::json!(message.truncated), - ); - } - return Ok(parsed); - } - - let body_bytes = body_bytes?; - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { - message: "Failed to parse Prebid response".to_string(), - })?; - if profile.debug && log::log_enabled!(log::Level::Trace) { - match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), - Err(error) => log::warn!("Prebid: failed to serialize response for logging: {error}"), - } - } - - let mut parsed = - parse_planned_prebid_openrtb(provider_id, input, &response_json, response_time_ms); - enrich_planned_prebid_metadata(profile, &response_json, &mut parsed); - log::info!( - "Prebid provider {provider_id} returned {} bids in {}ms", - parsed.bids.len(), - response_time_ms - ); - Ok(parsed) -} - -fn parse_planned_prebid_openrtb( - provider_id: &str, - input: &ProviderAuctionInput, - response_json: &Json, - response_time_ms: u64, -) -> AuctionResponse { - let Some(response) = response_json.as_object() else { - return AuctionResponse::error(provider_id, response_time_ms) - .with_metadata("error_type", serde_json::json!("parse_response")); - }; - match response.get("cur") { - None => {} - Some(Json::String(currency)) if currency.eq_ignore_ascii_case(DEFAULT_CURRENCY) => {} - Some(Json::String(currency)) => { - return AuctionResponse::no_bid(provider_id, response_time_ms) - .with_metadata("unsupported_currency", serde_json::json!(currency)); - } - Some(_) => { - return AuctionResponse::error(provider_id, response_time_ms) - .with_metadata("error_type", serde_json::json!("parse_response")); - } - } - - let dimensions_by_slot = build_bid_dimension_index(input); - let mut diagnostics = ResponseAdmissionDiagnostics::default(); - let mut bids = Vec::new(); - if let Some(seatbids) = response_json.get("seatbid").and_then(Json::as_array) { - for seatbid in seatbids { - let returned_seat = seatbid - .get("seat") - .and_then(Json::as_str) - .filter(|seat| !seat.is_empty()); - let delivery_bidder = returned_seat.unwrap_or("unknown"); - if let Some(entries) = seatbid.get("bid").and_then(Json::as_array) { - for entry in entries { - match parse_planned_prebid_bid( - entry, - delivery_bidder, - returned_seat, - &dimensions_by_slot, - ) { - Ok(bid) => bids.push(bid), - Err(reason) => { - diagnostics.record(reason); - if reason == BidRejectionReason::InvalidBid { - let impression = entry - .get("impid") - .and_then(Json::as_str) - .unwrap_or(""); - log::warn!( - "Prebid: failed to parse bid from seat '{delivery_bidder}' for imp '{impression}'" - ); - } - } - } - } - } - } - } - let mut parsed = if bids.is_empty() { - AuctionResponse::no_bid(provider_id, response_time_ms) - } else { - AuctionResponse::success(provider_id, bids, response_time_ms) - }; - diagnostics.attach_to(&mut parsed); - parsed -} - -fn enrich_planned_prebid_metadata( - profile: &PrebidProfilePlan, - response_json: &Json, - parsed: &mut AuctionResponse, -) { - let ext = response_json.get("ext"); - for key in ["responsetimemillis", "errors", "warnings"] { - if let Some(value) = ext.and_then(|ext| ext.get(key)) { - parsed.metadata.insert(key.to_string(), value.clone()); - } - } - if profile.debug { - if let Some(value) = ext.and_then(|ext| ext.get("debug")) { - parsed.metadata.insert("debug".to_string(), value.clone()); - } - if let Some(value) = ext - .and_then(|ext| ext.get("prebid")) - .and_then(|prebid| prebid.get("bidstatus")) - { - parsed - .metadata - .insert("bidstatus".to_string(), value.clone()); - } - } -} - -fn parse_planned_prebid_bid( - bid: &Json, - delivery_bidder: &str, - returned_seat: Option<&str>, - dimensions_by_slot: &BidDimensionIndex, -) -> Result { - let slot_id = bid - .get("impid") - .and_then(Json::as_str) - .filter(|slot_id| !slot_id.is_empty()) - .ok_or(BidRejectionReason::InvalidBid)? - .to_string(); - let width = parse_optional_bid_dimension(bid, "w")?; - let height = parse_optional_bid_dimension(bid, "h")?; - let (width, height) = resolve_bid_dimensions(dimensions_by_slot, &slot_id, width, height)?; - let price = bid - .get("price") - .and_then(Json::as_f64) - .filter(|price| price.is_finite() && *price >= 0.0) - .ok_or(BidRejectionReason::InvalidBid)?; - let creative = bid.get("adm").and_then(Json::as_str).map(String::from); - let cache_entry = bid - .get("ext") - .and_then(|ext| ext.get("prebid")) - .and_then(|prebid| prebid.get("cache")) - .and_then(|cache| cache.get("bids")); - let cache_id = cache_entry - .and_then(|cache| cache.get("cacheId")) - .and_then(Json::as_str) - .map(String::from); - let (cache_host, cache_path) = cache_entry - .and_then(|cache| cache.get("url")) - .and_then(Json::as_str) - .and_then(|value| { - ParsedUrl::parse(value) - .map_err(|error| log::debug!("PBS cache URL parse failed: {error}")) - .ok() - }) - .map(|url| { - let host = url.host_str().map(String::from); - let path = url.path().to_string(); - let path = (!path.is_empty() && path != "/").then_some(path); - (host, path) - }) - .unwrap_or((None, None)); - if cache_id.is_some() && cache_host.is_none() { - log::warn!( - "PBS bid has cache UUID but cache URL could not be parsed — creative will fail to render for slot '{slot_id}'" - ); - } - - Ok(AuctionBid { - slot_id, - price: Some(price), - currency: DEFAULT_CURRENCY.to_string(), - creative, - adomain: bid.get("adomain").and_then(Json::as_array).map(|domains| { - domains - .iter() - .filter_map(Json::as_str) - .map(String::from) - .collect() - }), - bidder: delivery_bidder.to_string(), - returned_seat: returned_seat.map(str::to_string), - width, - height, - nurl: bid.get("nurl").and_then(Json::as_str).map(String::from), - burl: bid.get("burl").and_then(Json::as_str).map(String::from), - bid_id: bid - .get("id") - .and_then(Json::as_str) - .filter(|value| !value.is_empty()) - .map(String::from), - ad_id: bid.get("adid").and_then(Json::as_str).map(String::from), - creative_id: bid.get("crid").and_then(Json::as_str).map(String::from), - renderer: None, - cache_id, - cache_host, - cache_path, - metadata: HashMap::new(), - }) -} - -// ============================================================================ -// Prebid Auction Provider -// ============================================================================ - -/// Legacy Prebid Server auction provider retained only for parity tests. -#[cfg(test)] -pub struct PrebidAuctionProvider { - config: LegacyPrebidServerConfig, - bid_param_override_engine: Arc, -} - -#[cfg(test)] -#[derive(Default)] -struct PrebidImpressionDisposition { - aps_only: usize, - invalid: usize, -} - -#[cfg(test)] -struct PrebidRequestBuild { - request: OpenRtbRequest, - disposition: PrebidImpressionDisposition, -} - -#[cfg(test)] -impl PrebidAuctionProvider { - #[cfg(test)] - fn new(config: LegacyPrebidServerConfig) -> Self { - Self::try_new(config).expect("should compile prebid bid param overrides") - } - - /// Create a new Prebid auction provider with validated override rules. - /// - /// # Errors - /// - /// Returns an error when the configured bidder-param override rules are invalid. - pub fn try_new(config: LegacyPrebidServerConfig) -> Result> { - Ok(Self { - bid_param_override_engine: Arc::new(BidParamOverrideEngine::try_from_config(&config)?), - config, - }) - } - - /// Returns the full Prebid Server `OpenRTB2` auction endpoint URL. - /// - /// Backward-compatible normalization: `server_url` may be configured as - /// either the PBS origin (path is appended here) or the full endpoint - /// already ending in `/openrtb2/auction` (used as-is, ignoring a trailing - /// slash) — both shapes produce the same request URL. - fn auction_endpoint_url(&self) -> String { - let base = self.config.server_url.trim_end_matches('/'); - if base.ends_with("/openrtb2/auction") { - base.to_string() - } else { - format!("{base}/openrtb2/auction") - } - } - - /// Build an enriched `OpenRTB` request and retain impression drop provenance. - fn build_openrtb( - &self, - request: &AuctionRequest, - context: &AuctionContext<'_>, - signer: Option<(&RequestSigner, String, &SigningParams)>, - request_info: RequestInfo, - ) -> PrebidRequestBuild { - let mut disposition = PrebidImpressionDisposition::default(); - let imps = request - .slots - .iter() - .filter_map(|slot| { - let slot_context = format!("slot '{}'", slot.id); - let formats: Vec<_> = slot - .formats - .iter() - .filter(|f| f.media_type == MediaType::Banner) - .filter_map(|f| { - let width = to_openrtb_i32(f.width, "format.w", &slot_context); - let height = to_openrtb_i32(f.height, "format.h", &slot_context); - - match (width, height) { - (Some(width), Some(height)) => Some(Format { - w: Some(width), - h: Some(height), - ..Default::default() - }), - _ => None, - } - }) - .collect(); - - if formats.is_empty() { - disposition.invalid += 1; - log::warn!( - "prebid: dropping imp '{}' — no valid banner formats after filtering", - slot.id - ); - return None; - } - - // Extract zone from trustedServer params (sent by the JS - // adapter from `mediaTypes.banner.name`, e.g. "header", "fixed_bottom"). - let zone: Option<&str> = slot - .bidders - .get(TRUSTED_SERVER_BIDDER) - .and_then(|p| p.get(ZONE_KEY)) - .and_then(Json::as_str); - - // Build the bidder map for PBS from two sources: - // 1. the `trustedServer` orchestrator entry, expanded into the - // configured PBS bidders (`expand_trusted_server_bidders`); - // 2. direct entries naming a configured PBS bidder. - // The JS adapter sends "trustedServer" as the bidder (our - // orchestrator adapter name); provider-specific keys like "aps" - // run their own auction and are skipped here. - // - // The two sources are collected separately so the merge below is - // deterministic regardless of `slot.bidders` HashMap iteration - // order: direct entries win, but an unusable direct value (`{}` or - // a non-object) must not clobber real params from the expansion. - let mut expanded: HashMap = HashMap::new(); - let mut direct: Vec<(String, Json)> = Vec::new(); - let mut excluded_aps = false; - for (name, params) in &slot.bidders { - if name.eq_ignore_ascii_case("aps") { - // APS is a separate OpenRTB provider. Never send native - // APS demand through PBS for the same cohort. - excluded_aps = true; - } else if name == TRUSTED_SERVER_BIDDER { - for (bidder, params) in - expand_trusted_server_bidders(&self.config.bidders, params) - { - if bidder.eq_ignore_ascii_case("aps") { - excluded_aps = true; - } else { - expanded.insert(bidder, params); - } - } - } else if self.config.bidders.iter().any(|bidder| bidder == name) { - direct.push((name.clone(), params.clone())); - } else { - // Any unrecognized key is likely a misconfiguration (a - // slot bidder absent from `config.bidders`) that silently - // yields an empty bidder map and a stored-request no-bid — - // log it so the drop is diagnosable. - log::debug!( - "prebid: dropping slot '{}' bidder '{}' — not in config.bidders and not a known provider key", - slot.id, - name - ); - } - } - - let mut bidder = expanded; - // Bidders whose params came from the slot itself rather than from - // `trustedServer` expansion. Only these can represent a publisher - // misconfiguration if they are dropped below; a dropped fabricated - // entry is the designed stored-request path. - let mut slot_supplied: HashSet = HashSet::new(); - for (name, params) in direct { - // Direct entries win over the expansion — except when an - // unusable direct value (`{}` or a non-object) would overwrite - // real params the expansion already supplied. PBS cannot tell a - // fabricated empty from an explicit one, so real params must not - // be discarded in favour of either. - let clobbers_real = is_unusable_bidder_params(¶ms) - && bidder - .get(&name) - .is_some_and(|existing| !is_unusable_bidder_params(existing)); - if !clobbers_real { - slot_supplied.insert(name.clone()); - bidder.insert(name, params); - } - } - - // Apply canonical and compatibility-derived rules in normalized - // order. An override rule can populate a bidder that arrived with - // empty params, promoting a fabricated empty into a valid bidder. - for (name, params) in &mut bidder { - self.bid_param_override_engine - .apply(BidParamOverrideFacts { bidder: name, zone }, params); - } - - // Drop any bidder whose params are still unusable after overrides — - // an empty `{}` or a non-object like `null`. Both are byte-identical - // to PBS regardless of whether they were fabricated or supplied - // explicitly, and shipping either gets the imp rejected; a single - // non-2xx then collapses into an auction-wide bid wipeout (see - // `parse_response`), so one misconfigured slot must never reach the - // wire. - // - // The two drop sources get different levels, one line per slot. A - // slot that supplied unusable params inline is a publisher mistake - // an operator can act on, so it warns — the PBS 400 body is - // otherwise discarded unless trace logging is enabled. Fabricated - // empties that no override rule filled are the designed - // creative-opportunity path (see `CreativeOpportunitySlot::to_ad_slot`), - // so they log at debug and do not bury the warn. - let mut dropped_slot_supplied: Vec = Vec::new(); - let mut dropped_fabricated: Vec = Vec::new(); - bidder.retain(|name, params| { - if is_unusable_bidder_params(params) { - if slot_supplied.contains(name) { - dropped_slot_supplied.push(name.clone()); - } else { - dropped_fabricated.push(name.clone()); - } - return false; - } - true - }); - if !dropped_slot_supplied.is_empty() { - // Sorted so the line is stable across `HashMap` iteration order. - dropped_slot_supplied.sort(); - log::warn!( - "prebid: dropping slot '{}' bidders [{}] — slot supplied empty or non-object params; slot falls back to its stored request", - slot.id, - dropped_slot_supplied.join(", ") - ); - } - if !dropped_fabricated.is_empty() { - dropped_fabricated.sort(); - log::debug!( - "prebid: dropping slot '{}' bidders [{}] — expansion fabricated empty params and no override rule filled them; slot falls back to its stored request", - slot.id, - dropped_fabricated.join(", ") - ); - } - - if excluded_aps && bidder.is_empty() { - disposition.aps_only += 1; - log::warn!( - "prebid: dropping imp '{}' because it contains only APS demand; refusing PBS stored-request fallback", - slot.id - ); - return None; - } - - // When no eligible PBS bidder params remain, tell PBS to resolve - // bidder config from the stored request keyed by this slot ID. This - // covers creative-opportunity slots whose PBS params live in stored - // requests, and slots whose configured bidders all resolved to - // unusable params and were dropped above. - // - // Note the fallback set is wider than "slots with no inline params": - // a slot carrying real inline params for a bidder absent from - // `config.bidders` also lands here — that bidder is never expanded, - // the configured bidders fabricate empties, the drop clears them, and - // the real params are lost. That param loss is pre-existing (see the - // debug log in the build loop); it is called out here because it - // means an operator with `config.bidders` set but no stored request - // provisioned for such a slot gets a no-bid. - let storedrequest = if bidder.is_empty() { - Some(ImpStoredRequest { - id: slot.id.clone(), - }) - } else { - None - }; - - Some(Imp { - id: Some(slot.id.clone()), - banner: Some(Banner { - format: formats, - ..Default::default() - }), - bidfloor: slot.floor_price, - // NOTE: Currency defaults to DEFAULT_CURRENCY. If - // multi-currency support is needed, this should come from - // config or the AdSlot itself. - bidfloorcur: slot.floor_price.map(|_| DEFAULT_CURRENCY.to_string()), - secure: Some(true), // require HTTPS creatives - tagid: Some(slot.id.clone()), - ext: ImpExt { - prebid: PrebidImpExt { - bidder, - storedrequest, - }, - } - .to_ext(), - ..Default::default() - }) - }) - .collect(); - - // Build page URL with debug query params if configured - let page_url = request.publisher.page_url.as_ref().map(|url| { - if let Some(ref params) = self.config.debug_query_params { - append_query_params(url, params) - } else { - url.clone() - } - }); - - // Build user object — populate consent at both OpenRTB 2.6 top-level - // and Prebid ext-based locations (dual placement). - // In cookies_only mode, cookie-sourced consent travels through the - // forwarded Cookie header. Policy-sourced consent has no inbound cookie - // to forward, so carry it in the OpenRTB body instead. - let consent_ctx = request.user.consent.as_ref().filter(|ctx| { - self.config.consent_forwarding.includes_body_consent() - || !matches!(ctx.source, crate::consent::ConsentSource::Cookie) - }); - let raw_tc = consent_ctx.and_then(|c| c.raw_tc_string.clone()); - let user = Some(User { - id: request.user.id.clone(), - // OpenRTB 2.6 top-level consent field - consent: raw_tc.clone(), - ext: UserExt { - // Prebid ext-based consent field - consent: raw_tc, - consented_providers_settings: consent_ctx - .and_then(|c| c.raw_ac_string.as_ref()) - .map(|ac| ConsentedProvidersSettings { - consented_providers: Some(ac.clone()), - }), - // EIDs resolved from the KV identity graph and consent-gated - // in `handle_auction` via `gate_eids_by_consent`. - eids: request.user.eids.clone(), - } - .to_ext(), - ..Default::default() - }); - - // Extract DNT header and Accept-Language from the original request - let dnt = context - .request - .headers() - .get("DNT") - .and_then(|value| value.to_str().ok()) - .and_then(|value| { - if value.trim() == "1" { - Some(true) - } else { - None - } - }); - - let language = context - .request - .headers() - .get(header::ACCEPT_LANGUAGE) - .and_then(|value| value.to_str().ok()) - .and_then(|v| { - // Extract the primary ISO-639 language tag (e.g., "en" from - // "en-US,en;q=0.9"). Strip the region subtag so bidders get a - // normalised two-letter code that maximises match quality. - v.split(',') - .next() - .and_then(|tag| tag.split(';').next()) - .map(|tag| { - tag.split('-') - .next() - .expect("should have at least one split segment") - .trim() - .to_string() - }) - .filter(|s| !s.is_empty()) - }); - - // Build device object with user-agent, client IP, geo, DNT, and language. - // Forwarding the real client IP is critical: without it PBS infers the - // IP from the incoming connection (a data-center / edge IP), causing - // bidders like PubMatic to filter the traffic as non-human. - // - // When `request.device` is `None` we still construct a minimal `Device` - // if DNT or language were extracted from HTTP headers, so those signals - // are never silently discarded. - let device = request - .device - .as_ref() - .map(|d| Device { - ua: d.user_agent.clone(), - ip: d.ip.clone(), - geo: d.geo.as_ref().map(|geo| Geo { - country: Some(geo.country.clone()), - city: Some(geo.city.clone()), - region: geo.region.clone(), - lat: Some(geo.latitude), - lon: Some(geo.longitude), - // DMA/metro code: convert i64 to string for OpenRTB. - // Fastly returns 0 for "no metro code"; negative values - // are not realistic for DMA codes so the > 0 guard is - // sufficient. - metro: if geo.metro_code > 0 { - Some(geo.metro_code.to_string()) - } else { - None - }, - r#type: Some(2), - ..Default::default() - }), - dnt, - // Clone needed: `language` is also used in the `or_else` - // fallback below when `request.device` is `None`. - language: language.clone(), - ..Default::default() - }) - .or_else(|| { - if dnt.is_some() || language.is_some() { - Some(Device { - dnt, - language, - ..Default::default() - }) - } else { - None - } - }); - - // Build regs object from ConsentContext, populating both OpenRTB 2.6 - // top-level fields and `regs.ext` for Prebid Server compatibility. - let regs = Self::build_regs(consent_ctx); - - // Build ext object - let (version, signature, kid, ts) = signer - .map(|(s, sig, params)| { - ( - Some(SIGNING_VERSION.to_string()), - Some(sig), - Some(s.kid.clone()), - Some(params.timestamp), - ) - }) - .unwrap_or((None, None, None, None)); - - let debug_enabled = self.config.debug; - - let ext = RequestExt { - prebid: Some(PrebidExt { - debug: debug_enabled.then_some(true), - returnallbidstatus: debug_enabled.then_some(true), - }), - trusted_server: Some(TrustedServerExt { - version, - signature, - kid, - request_host: Some(request_info.host), - request_scheme: Some(request_info.scheme), - ts, - }), - } - .to_ext(); - - // Extract Referer header for site.ref - let referer = context - .request - .headers() - .get(header::REFERER) - .and_then(|value| value.to_str().ok()) - .map(std::string::ToString::to_string); - - // Advertise the effective auction budget, not the raw provider config: - // the orchestrator caps `context.timeout_ms` to the remaining auction - // budget, and the edge backend stops waiting after that long. Telling - // PBS it has more time than the edge will wait turns partial bids into - // edge timeouts. - let tmax = to_openrtb_i32(context.timeout_ms, "tmax", "request"); - - let request = OpenRtbRequest { - id: Some(request.id.clone()), - imp: imps, - site: Some(Site { - domain: Some(request.publisher.domain.clone()), - page: page_url, - r#ref: referer, - publisher: Some(Publisher { - domain: Some(request.publisher.domain.clone()), - ..Default::default() - }), - ..Default::default() - }), - user, - device, - regs, - test: self.config.test_mode.then_some(true), - tmax, - cur: vec![DEFAULT_CURRENCY.to_string()], - ext, - ..Default::default() - }; - - PrebidRequestBuild { - request, - disposition, - } - } - - /// Convert an auction request to `OpenRTB` format with all enrichments. - #[cfg(test)] - fn to_openrtb( - &self, - request: &AuctionRequest, - context: &AuctionContext<'_>, - signer: Option<(&RequestSigner, String, &SigningParams)>, - request_info: RequestInfo, - ) -> OpenRtbRequest { - self.build_openrtb(request, context, signer, request_info) - .request - } - - /// Builds the `regs` object from a [`ConsentContext`]. - /// - /// Populates consent fields at **both** `OpenRTB` 2.6 top-level locations - /// and the `regs.ext.*` locations that Prebid Server reads today. - /// - /// Returns [`None`] if no consent-relevant data is present (avoids sending - /// an empty `regs` object to Prebid Server). - fn build_regs(consent_ctx: Option<&crate::consent::ConsentContext>) -> Option { - let ctx = consent_ctx?; - - let has_data = ctx.gdpr_applies - || ctx.raw_us_privacy.is_some() - || ctx.raw_gpp_string.is_some() - || ctx.gpp_section_ids.is_some() - || ctx.gpc; - - if !has_data { - return None; - } - - // Use jurisdiction to inform the GDPR flag: if we know the user is in - // a GDPR jurisdiction, signal that even without a TCF string (e.g., - // GPC-only requests from EU users). When jurisdiction is unknown and - // no TCF signal exists, omit the field rather than falsely signaling - // "GDPR does not apply." - let in_gdpr_jurisdiction = matches!( - ctx.jurisdiction, - crate::consent::jurisdiction::Jurisdiction::Gdpr - ); - let gdpr = if ctx.gdpr_applies || in_gdpr_jurisdiction { - Some(true) - } else if matches!( - ctx.jurisdiction, - crate::consent::jurisdiction::Jurisdiction::Unknown - ) { - None - } else { - Some(false) - }; - - // Dual-placement: OpenRTB 2.6 top-level fields AND `regs.ext` for - // backward compatibility with older exchanges. Extract top-level - // values first, then clone once into the ext to avoid extra copies. - let us_privacy = ctx.raw_us_privacy.clone(); - let gpp = ctx.raw_gpp_string.clone(); - let gpp_sid = ctx.gpp_section_ids.clone(); - - // RegsExt uses u8 for GDPR (Prebid convention) while the top-level - // Regs uses bool (OpenRTB proto). Map accordingly. - let gdpr_u8 = gdpr.map(u8::from); - let gpp_sid_u16 = gpp_sid.clone(); - let ext = RegsExt { - gdpr: gdpr_u8, - us_privacy: us_privacy.clone(), - gpp: gpp.clone(), - gpp_sid: gpp_sid_u16, - }; - - Some(Regs { - coppa: None, - gdpr, - us_privacy, - gpp, - gpp_sid: gpp_sid - .map(|ids| ids.into_iter().map(i32::from).collect()) - .unwrap_or_default(), - ext: ext.to_ext(), - }) - } - - /// Parse `OpenRTB` response into auction response. - fn parse_openrtb_response(&self, json: &Json, response_time_ms: u64) -> AuctionResponse { - let mut bids = Vec::new(); - - if let Some(seatbids) = json.get("seatbid").and_then(|v| v.as_array()) { - for seatbid in seatbids { - let seat = seatbid - .get("seat") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - if let Some(bid_array) = seatbid.get("bid").and_then(|v| v.as_array()) { - for bid_obj in bid_array { - match self.parse_bid(bid_obj, seat) { - Ok(bid) => bids.push(bid), - Err(()) => { - let impid = bid_obj - .get("impid") - .and_then(|v| v.as_str()) - .unwrap_or(""); - log::warn!( - "Prebid: failed to parse bid from seat '{seat}' for imp '{impid}'" - ); - } - } - } - } - } - } - - if bids.is_empty() { - AuctionResponse::no_bid(PREBID_INTEGRATION_ID, response_time_ms) - } else { - AuctionResponse::success(PREBID_INTEGRATION_ID, bids, response_time_ms) - } - } - - /// Enrich an [`AuctionResponse`] with metadata extracted from the Prebid - /// Server response `ext`. - /// - /// Always-on fields: `responsetimemillis`, `errors`, `warnings`. - /// Debug-only fields (gated on `config.debug`): `debug`, `bidstatus`. - fn enrich_response_metadata( - &self, - response_json: &Json, - auction_response: &mut AuctionResponse, - ) { - let ext = response_json.get("ext"); - - // Always attach per-bidder timing and diagnostics. - if let Some(rtm) = ext.and_then(|e| e.get("responsetimemillis")) { - auction_response - .metadata - .insert("responsetimemillis".to_string(), rtm.clone()); - } - if let Some(errors) = ext.and_then(|e| e.get("errors")) { - auction_response - .metadata - .insert("errors".to_string(), errors.clone()); - } - if let Some(warnings) = ext.and_then(|e| e.get("warnings")) { - auction_response - .metadata - .insert("warnings".to_string(), warnings.clone()); - } - - // When debug is enabled, surface httpcalls, resolvedrequest, and - // per-bid status from the Prebid Server response. - if self.config.debug { - if let Some(debug) = ext.and_then(|e| e.get("debug")) { - auction_response - .metadata - .insert("debug".to_string(), debug.clone()); - } - if let Some(bidstatus) = ext - .and_then(|e| e.get("prebid")) - .and_then(|p| p.get("bidstatus")) - { - auction_response - .metadata - .insert("bidstatus".to_string(), bidstatus.clone()); - } - } - } - - async fn parse_response_inner( - &self, - response: PlatformResponse, - response_time_ms: u64, - auction_id: Option<&str>, - ) -> Result> { - let response = response.response; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - - // Parse response — collect_response_bounded caps memory from misbehaving providers. - let body_bytes = collect_response_bounded( - response.into_body(), - UPSTREAM_RTB_MAX_RESPONSE_BYTES, - "prebid", - ) - .await - .change_context(TrustedServerError::Prebid { - message: "Failed to read Prebid response body".to_string(), - }); - - if !status.is_success() { - let auction_id = auction_id.unwrap_or(""); - log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); - - let body_bytes = match body_bytes { - Ok(body_bytes) => Some(body_bytes), - Err(error) => { - log::warn!( - "Prebid auction {auction_id:?} failed to read non-success response body: {error:?}" - ); - None - } - }; - - if self.config.debug - && let Some(body_bytes) = body_bytes.as_deref() - { - match prebid_body_preview(body_bytes) { - Some(preview) => { - let truncation = if preview.truncated { - " (truncated)" - } else { - "" - }; - log::warn!( - "Prebid auction {auction_id:?} error response body preview{truncation}: {}", - preview.text - ); - } - None => log::warn!( - "Prebid auction {auction_id:?} returned an empty error response body" - ), - } - } - - let status_code = status.as_u16(); - let mut auction_response = - AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) - .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) - .with_metadata("http_status", serde_json::json!(status_code)) - .with_metadata( - "message", - serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), - ); - - let upstream_message = if self.config.debug { - body_bytes.as_deref().and_then(|body_bytes| { - extract_prebid_error_message(body_bytes, content_type.as_deref()) - }) - } else { - None - }; - if let Some(message) = upstream_message { - auction_response.metadata.insert( - "upstream_message".to_string(), - serde_json::json!(message.text), - ); - auction_response.metadata.insert( - "upstream_message_truncated".to_string(), - serde_json::json!(message.truncated), - ); - } - - return Ok(auction_response); - } - - let body_bytes = body_bytes?; - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { - message: "Failed to parse Prebid response".to_string(), - })?; - - // Log the full response body when debug is enabled to surface - // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. - if self.config.debug && log::log_enabled!(log::Level::Trace) { - match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), - Err(e) => { - log::warn!("Prebid: failed to serialize response for logging: {e}"); - } - } - } - - let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); - self.enrich_response_metadata(&response_json, &mut auction_response); - - log::info!( - "Prebid returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); - - Ok(auction_response) - } - - fn should_suppress_bid_notifications(&self, bidder: &str) -> bool { - self.config.suppress_nurl - || self - .config - .suppress_nurl_bidders - .iter() - .any(|suppressed_bidder| suppressed_bidder == bidder) - } - - /// Parse a single bid from `OpenRTB` response. - fn parse_bid(&self, bid_obj: &Json, seat: &str) -> Result { - let slot_id = bid_obj - .get("impid") - .and_then(|v| v.as_str()) - .ok_or(())? - .to_string(); - - let price = bid_obj - .get("price") - .and_then(serde_json::Value::as_f64) - .ok_or(())?; - - let creative = bid_obj - .get("adm") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); - - let width = bid_obj - .get("w") - .and_then(serde_json::Value::as_u64) - .and_then(|v| u32::try_from(v).ok()) - .unwrap_or(0); - let height = bid_obj - .get("h") - .and_then(serde_json::Value::as_u64) - .and_then(|v| u32::try_from(v).ok()) - .unwrap_or(0); - - let suppress_bid_notifications = self.should_suppress_bid_notifications(seat); - let nurl = if suppress_bid_notifications { - None - } else { - bid_obj - .get("nurl") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) - }; - - let burl = if suppress_bid_notifications { - None - } else { - bid_obj - .get("burl") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) - }; - - // `adid` is the creative/ad identifier. The OpenRTB `id` is the bid ID, - // not an ad ID, so it is not used as a fallback: surfacing it as `ad_id` - // (which is exposed raw in the debug bid) would mislead any consumer that - // treats `ad_id` as a creative identifier. Absent `adid`, `ad_id` is None. - // The bid ID is carried separately in `bid_id` instead. An empty `id` is - // treated as absent — a blank hb_adid is falsey on the page, so it would - // be no better than omitting the key. - let bid_id = bid_obj - .get("id") - .and_then(|v| v.as_str()) - .filter(|id| !id.is_empty()) - .map(String::from); - let ad_id = bid_obj - .get("adid") - .and_then(|v| v.as_str()) - .map(String::from); - let creative_id = bid_obj - .get("crid") - .and_then(|v| v.as_str()) - .map(String::from); - - let adomain = bid_obj - .get("adomain") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) - .collect() - }); - - // Extract PBS Cache coordinates from ext.prebid.cache.bids - let cache_entry = bid_obj - .get("ext") - .and_then(|e| e.get("prebid")) - .and_then(|p| p.get("cache")) - .and_then(|c| c.get("bids")); - - let cache_id = cache_entry - .and_then(|c| c.get("cacheId")) - .and_then(|v| v.as_str()) - .map(String::from); - - let (cache_host, cache_path) = cache_entry - .and_then(|c| c.get("url")) - .and_then(|v| v.as_str()) - .and_then(|url_str| { - ParsedUrl::parse(url_str) - .map_err(|e| log::debug!("PBS cache URL parse failed: {}", e)) - .ok() - }) - .map(|u| { - let host = u.host_str().map(String::from); - // path() returns "/" for root — only use if non-trivial - let path = u.path().to_string(); - let path = if path.is_empty() || path == "/" { - None - } else { - Some(path) - }; - (host, path) - }) - .unwrap_or((None, None)); - - // Guard: if we extracted a cache UUID but couldn't extract the host, - // the bid will have hb_adid set but no endpoint to fetch from — creative will fail. - if cache_id.is_some() && cache_host.is_none() { - log::warn!( - "PBS bid has cache UUID but cache URL could not be parsed — \ - creative will fail to render for slot '{}'", - slot_id - ); - } - - Ok(AuctionBid { - slot_id, - price: Some(price), // Prebid provides decoded prices - currency: DEFAULT_CURRENCY.to_string(), - creative, - adomain, - bidder: seat.to_string(), - returned_seat: None, - width, - height, - nurl, - burl, - bid_id, - ad_id, - creative_id, - renderer: None, - cache_id, - cache_host, - cache_path, - metadata: std::collections::HashMap::new(), - }) - } -} - -#[cfg(test)] -#[async_trait(?Send)] -impl AuctionProvider for PrebidAuctionProvider { - fn provider_name(&self) -> &str { - PREBID_INTEGRATION_ID - } - - async fn request_bids( - &self, - request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - let request_start = web_time::Instant::now(); - log::info!("Prebid: requesting bids for {} slots", request.slots.len()); - - // `ext.trusted_server.request_host` must be the publisher's own domain - // (matching `site.domain`/`publisher.domain` below), not the raw edge - // `Host` header of the incoming `/auction` request. On the SSAT proxy - // path the browser calls `/auction` against the trusted-server edge - // domain (e.g. `ts.example.com`), which is not what PBS's - // `trusted_server` verification module expects — see the "Host header - // value expected by the receiving service" doc on `SigningParams`. - // `scheme` is canonical `"https"` rather than detected from the - // incoming request: the publisher origin is always https in - // production, and PBS verification is expected to match on that, - // not on the edge's local scheme (e.g. http under `fastly compute - // serve`). - let request_info = RequestInfo { - host: request.publisher.domain.clone(), - scheme: "https".to_owned(), - }; - - // Create signer and compute signature if request signing is enabled - let signer_with_signature = - if let Some(request_signing_config) = &context.settings.request_signing { - if request_signing_config.enabled { - let signer = RequestSigner::from_services(context.services)?; - let params = SigningParams::new( - request.id.clone(), - request_info.host.clone(), - request_info.scheme.clone(), - ); - let signature = signer.sign_request(¶ms)?; - Some((signer, signature, params)) - } else { - None - } - } else { - None - }; - - let PrebidRequestBuild { - request: openrtb, - disposition, - } = self.build_openrtb( - request, - context, - signer_with_signature - .as_ref() - .map(|(signer, signature, params)| (signer, signature.clone(), params)), - request_info, - ); - - if openrtb.imp.is_empty() { - let all_slots_are_aps_only = !request.slots.is_empty() - && disposition.invalid == 0 - && disposition.aps_only == request.slots.len(); - if all_slots_are_aps_only { - log::info!( - "Prebid: returning no-bid because all {} valid impressions are APS-only", - disposition.aps_only - ); - return Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( - PREBID_INTEGRATION_ID, - request_start.elapsed().as_millis() as u64, - ))); - } - - log::info!("Prebid: skipping request — no valid impressions after filtering"); - return Err(Report::new(TrustedServerError::Prebid { - message: "No valid impressions after filtering".to_string(), - })); - } - - // Log the outgoing OpenRTB request for debugging. - if log::log_enabled!(log::Level::Debug) { - match serde_json::to_string_pretty(&openrtb) { - Ok(json) => log::debug!( - "Prebid OpenRTB request to {}:\n{}", - self.auction_endpoint_url(), - json - ), - Err(e) => { - log::warn!("Prebid: failed to serialize OpenRTB request for logging: {e}") - } - } - } - - // Create HTTP request - let mut pbs_req = http::Request::builder() - .method(http::Method::POST) - .uri(self.auction_endpoint_url()) - .body(EdgeBody::empty()) - .change_context(TrustedServerError::Prebid { - message: "Failed to build Prebid request".to_string(), - })?; - copy_request_headers( - context.request, - &mut pbs_req, - self.config.consent_forwarding, - context.services.client_info().client_ip, - ); - - let pbs_body = serde_json::to_vec(&openrtb).change_context(TrustedServerError::Prebid { - message: "Failed to serialize Prebid request body".to_string(), - })?; - pbs_req.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); - *pbs_req.body_mut() = EdgeBody::from(pbs_body); - - // Uses context.timeout_ms (auction-scoped) rather than the 15 s fixed - // timeout in ensure_integration_backend, which is for proxy endpoints. - // Send request asynchronously with auction-scoped timeout - let backend_name = ensure_integration_backend_with_timeout( - context.services, - &self.config.server_url, - "prebid", - Duration::from_millis(u64::from(context.timeout_ms)), - ) - .change_context(TrustedServerError::Auction { - message: format!( - "Failed to resolve backend for Prebid Server endpoint: {}", - self.config.server_url - ), - })?; - let pending = context - .services - .http_client() - .send_async(PlatformHttpRequest::new(pbs_req, backend_name)) - .await - .change_context(TrustedServerError::Prebid { - message: "Failed to send async request to Prebid Server".to_string(), - })?; - - Ok(ProviderRequestOutcome::pending(pending)) - } - - async fn parse_response( - &self, - response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - self.parse_response_inner(response, response_time_ms, None) - .await - } - - async fn parse_response_with_context( - &self, - response: PlatformResponse, - response_time_ms: u64, - request: &AuctionRequest, - _context: &AuctionContext<'_>, - ) -> Result> { - self.parse_response_inner(response, response_time_ms, Some(request.id.as_str())) - .await - } - - fn supports_media_type(&self, media_type: &MediaType) -> bool { - matches!(media_type, MediaType::Banner) - } - - fn timeout_ms(&self) -> u32 { - self.config.timeout_ms - } - - fn is_enabled(&self) -> bool { - self.config.enabled - } - - fn backend_name(&self, services: &RuntimeServices, timeout_ms: u32) -> Option { - predict_integration_backend_name( - services, - &self.config.server_url, - PREBID_INTEGRATION_ID, - Duration::from_millis(u64::from(timeout_ms)), - ) - .inspect_err(|e| { - log::error!( - "Failed to predict backend name for Prebid server URL '{}': {e:?}", - self.config.server_url - ); - }) - .ok() - } -} - -// ============================================================================ -// Provider Auto-Registration -// ============================================================================ - -/// Auto-register Prebid provider based on settings configuration. -/// -/// This function checks the settings for Prebid configuration and returns -/// the provider if enabled. -/// -/// # Errors -/// -/// Returns an error when the Prebid provider is enabled with invalid -/// configuration. -#[cfg(test)] -pub fn register_auction_provider( - settings: &Settings, -) -> Result>, Report> { - let Some(integration) = build(settings)? else { - log::info!("Prebid auction provider not registered: integration not found or disabled"); - return Ok(Vec::new()); - }; - - if let Some(config) = integration.legacy_config.as_ref() { - log::info!( - "Registering Prebid auction provider (server_url={})", - config.server_url - ); - } - if integration.config.debug { - log::warn!( - "Prebid debug mode is ON — debug data (httpcalls, resolvedrequest, \ - bidstatus) will be included in /auction responses" - ); - } - - Ok(vec![Arc::new(integration.auction_provider())]) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use crate::auction::formats::convert_to_openrtb_response; - use crate::auction::orchestrator::OrchestrationResult; - use crate::auction::plan::{ - AuctionPlanConfig, BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, - ProviderId, RoutingMode, - }; - use crate::auction::test_support::{ - canonical_parity_auction_request, - create_test_auction_context as shared_test_auction_context, - }; - use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, - }; - - use crate::consent::{ConsentContext, ConsentSource}; - use crate::geo::GeoInfo; - use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; - use crate::integrations::{ - AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, - }; - use crate::platform::test_support::{ - HashMapConfigStore, HashMapSecretStore, NoopConfigStore, NoopGeo, NoopHttpClient, - NoopSecretStore, StubHttpClient, build_services_with_config_secret_and_http_client, - build_services_with_http_client, build_services_with_http_client_and_client_ip, - }; - use crate::platform::{ - ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, RuntimeServices, - }; - use crate::settings::Settings; - use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; - use crate::test_support::tests::create_test_settings; - use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD; - use bytes::Bytes; - use http::Method; - use serde_json::json; - use std::collections::{BTreeMap, HashMap}; - use std::io::Cursor; - use std::str::FromStr as _; - - #[test] - fn external_bundle_sha256_validation_matches_hex_pattern() { - use validator::Validate as _; - - let config = |sha: &str| -> PrebidIntegrationConfig { - serde_json::from_value(serde_json::json!({ - "external_bundle_sha256": sha, - })) - .expect("should deserialize prebid config") - }; - - // Exactly 64 hex digits (either case) passes. - config(&"a".repeat(64)) - .validate() - .expect("64-char lowercase hex sha256 should pass"); - config("ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789") - .validate() - .expect("mixed-case 64-char hex sha256 should pass"); - - // Wrong length or non-hex characters are rejected. - for bad in [ - "a".repeat(63), - "a".repeat(65), - "g".repeat(64), - String::new(), - ] { - config(&bad) - .validate() - .expect_err(&format!("invalid sha256 {bad:?} should be rejected")); - } - } - - fn make_settings() -> Settings { - create_test_settings() - } - - #[test] - fn injected_prebid_config_escapes_every_less_than_sign() { - let config_json = serialize_injected_prebid_config(&json!({ - "accountId": "x C[endpoints::convert_tsjs_to_auction_request] C --> D[routing::route_auction] D --> E[provider::GenericOpenRtbProvider builds requests] - E --> F[orchestrator::AuctionOrchestrator dispatches providers] - F --> G[Provider responses are normalized] - G --> H{Mediator configured?} - H -->|Yes| I[Mediator selects bids] + E --> F[orchestrator::AuctionOrchestrator dispatches demand sources] + F --> G[Demand responses are normalized] + G --> H{Ad server configured?} + H -->|Yes| I[Ad server selects bids] H -->|No| J[Orchestrator ranks bids locally] I --> K[formats::convert_to_openrtb_response] J --> K @@ -27,80 +29,80 @@ flowchart TB ``` Each adapter owns transport routing in its `app.rs`. Core request handling stays -in `auction::endpoints`, so no provider or profile depends on Fastly types. +in `auction::endpoints`, so no demand implementation depends on Fastly types. `handle_auction` performs these steps: 1. Enforce the body limit and parse the Trusted Server ad-unit request. -2. Apply the disabled-auction and consent gates before provider work. +2. Apply the disabled-auction and consent gates before any demand work. 3. Consume the request's existing EC and consent context. The endpoint does not generate an EC ID. 4. Convert the request with `convert_tsjs_to_auction_request`. 5. Route slots and bidder params through the compiled `AuctionPlan`. -6. Run the plan-backed orchestrator and optional mediator. +6. Run the plan-backed orchestrator and the optional ad server. 7. Build the OpenRTB response with `convert_to_openrtb_response`. ## Configuration boundary `auction::compile_auction_plan` is the single settings-to-plan boundary used by -startup and operator validation. It validates: - -- provider IDs, protocols, profiles, endpoints, timeouts, and routing modes; -- bidder-to-provider ownership; -- profile-specific configuration; -- mediator and request-signing references; -- bounded configuration values. +startup and operator validation. It reads `[demand]`, `[ad-server]` and +`[auction.bidders]` and validates: + +- demand source and ad server names, which must be snake_case +- a settings table its type's `modules` or `module` does not select +- an `implementation` no builder registered, naming the ones that are +- endpoints, which must be HTTPS or HTTP to a loopback host, with no + credentials or fragment +- timeouts, routing modes and notification bounds +- the settings each implementation accepts, since each one rejects unknown keys +- bidder-to-demand ownership, and +- request-signing structure. Adapters then call `AuctionPlan::validate_for_target` for backend naming, fan-out support, and target resource limits. -A plan-backed orchestrator contains generic OpenRTB providers compiled from the -plan. `AuctionOrchestrator::register_provider` and the old concrete Prebid +A plan-backed orchestrator contains generic OpenRTB demand sources compiled from +the plan. `AuctionOrchestrator::register_provider` and the old concrete Prebid provider remain test-only parity code. They are not extension APIs. ## Routing `routing::route_auction` normalizes the browser `trustedServer` envelope and -produces one `ProviderAuctionInput` per provider. +produces one `ProviderAuctionInput` per demand source. - `explicit` sends a slot only when it has bidder demand assigned to that - provider, or trusted stored-request demand where the profile supports it. + source, or trusted stored-request demand where the implementation supports it. - `all_eligible` sends every compatible banner slot without copying another - provider's bidder params. -- `prebid-server` requires `explicit`. PBS rejects impressions that have neither + source's bidder params. +- `auction.prebid-server` requires `explicit`. PBS rejects impressions that have neither bidder demand nor a stored-request reference. - APS normally uses `all_eligible` because APS participates across eligible inventory without browser bidder params. -Each `[auction.bidders.]` route has one provider owner. Unlisted page -bidders remain browser demand. +Each `[auction.bidders.]` route has one owner, named by its +`[demand]` table name. Unlisted page bidders remain browser demand. -## Provider execution +## Demand execution `provider::GenericOpenRtbProvider` owns the shared transport path for the -`standard`, `prebid-server`, and `aps` profiles. Profiles receive routed and -privacy-approved facts, not the raw inbound request. - -The orchestrator launches all eligible providers before collecting responses. -It uses adapter `PlatformHttpClient` handles and predicted backend names for -correlation. Provider launch, transport, HTTP, parse, and admission failures are -provider-local when another provider can continue. +`auction-protocol.openrtb`, `auction.prebid-server` and `auction.aps` implementations. An implementation receives +routed and privacy-approved facts, not the raw inbound request. -When no mediator is configured, the orchestrator selects the highest decoded -CPM per slot and applies floors locally. When a mediator is configured, it sends -normalized provider responses to the separately registered mediator and falls -back to local ranking when mediation cannot run. +The orchestrator launches all eligible demand sources before collecting +responses. It uses adapter `PlatformHttpClient` handles and predicted backend +names for correlation. Launch, transport, HTTP, parse and admission failures are +local to one demand source when another can continue. ## Response admission -Providers normalize successful upstream bids into `auction::types::Bid`. +Demand sources normalize successful upstream bids into `auction::types::Bid`. Admission checks keep malformed or unrequested bids out of ranking. Aggregate metadata reports bounded rejection counts without retaining raw upstream bid payloads. Notification suppression runs after normalization and matches exact returned -OpenRTB seats. Provider response identity uses the configured provider ID, such -as `pbs-main`. +OpenRTB seats. Response identity uses the configured demand source name, such +as `pbs_main`. ## Creative delivery @@ -121,55 +123,65 @@ response. enabled = true timeout_ms = 2000 -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +modules = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "auction.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.bidders.example-server] -provider = "pbs-main" - -[auction.providers.aps-main] -protocol = "openrtb-2.6" -profile = "aps" +[demand.aps_main] +implementation = "auction.aps" endpoint = "https://aps.example.com/e/pb/bid" routing = "all_eligible" -profile_config = { account_id = "example-account" } +account_id = "example-account" + +[auction.bidders.example-server] +module = "pbs_main" + +[ad-server] +module = "mock" + +[ad-server.mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 ``` -Provider endpoints must be absolute HTTPS URLs. Replace all example values -before enabling an auction. +Every demand table carries an `implementation` line naming the +implementation by its module path, which is how two Prebid Servers run side +by side under names of their own, and `[ad-server]` writes `ad-server.mock` +as `mock`. Endpoints must be HTTPS, or HTTP to `127.0.0.1`, `::1` or +`localhost`. Replace all example values before enabling an auction. ## Code map - `mod.rs` compiles plans and builds the shared orchestrator. - `endpoints.rs` handles `POST /auction` and converts the browser request. - `plan.rs` owns plan validation and target capability checks. -- `profile.rs` owns typed OpenRTB profile configuration. -- `routing.rs` assigns slots and bidder params to providers. +- `demand.rs` owns the demand and ad server implementation seam. +- `routing.rs` assigns slots and bidder params to demand sources. - `openrtb.rs` builds shared requests and parses standard responses. -- `provider.rs` runs plan-backed provider requests and profile-specific parsing. -- `orchestrator.rs` owns fan-out, deadlines, mediation, and local ranking. +- `provider.rs` runs plan-backed demand requests and per-implementation parsing. +- `orchestrator.rs` owns fan-out, deadlines, the ad server call, and local + ranking. - `formats.rs` builds direct endpoint responses and processes creatives. - `types.rs` contains normalized auction request, response, slot, and bid types. ## Testing Use `compile_auction_plan` in tests, then construct the orchestrator and -integration registry from the same `Arc`. Profile tests should -cover typed configuration, exact request output, response admission, routing, -provider-local failures, and target validation. +integration registry from the same `Arc`. Tests for one +implementation should cover its typed settings, exact request output, response +admission, routing, local failures, and target validation. Run target-matched aliases rather than bare workspace tests: diff --git a/crates/trusted-server-core/src/auction/context.rs b/crates/trusted-server-core/src/auction/context.rs index f7fd782942..467801726f 100644 --- a/crates/trusted-server-core/src/auction/context.rs +++ b/crates/trusted-server-core/src/auction/context.rs @@ -1,6 +1,6 @@ //! Context query-parameter forwarding for auction providers. //! -//! Provides a config-driven mechanism for ad-server / mediator providers to +//! Provides a config-driven mechanism for ad server providers to //! forward integration-supplied data (e.g. audience segments) as URL query //! parameters without hard-coding integration-specific knowledge. @@ -26,12 +26,12 @@ pub enum ContextValue { /// Mapping from auction-request context keys to query-parameter names. /// -/// Used by ad-server / mediator providers to forward integration-supplied data +/// Used by ad server providers to forward integration-supplied data /// (e.g. audience segments) as URL query parameters without hard-coding /// integration-specific knowledge. /// /// ```toml -/// [integrations.adserver_mock.context_query_params] +/// [ad-server.mock.context_query_params] /// permutive_segments = "permutive" /// lockr_ids = "lockr" /// ``` diff --git a/crates/trusted-server-core/src/auction/demand.rs b/crates/trusted-server-core/src/auction/demand.rs new file mode 100644 index 0000000000..c9800bb36c --- /dev/null +++ b/crates/trusted-server-core/src/auction/demand.rs @@ -0,0 +1,290 @@ +//! Demand and ad server implementations, the seam through which an integration +//! supplies the providers an auction calls. +//! +//! A deployment selects its demand sources with `[demand] modules = [...]` and +//! its ad server with `[ad-server] module = "..."`. Each selected name resolves +//! to an implementation that an integration builder registers, so core names no +//! vendor. The common `OpenRTB` driver builds every standard request field, sends +//! the request and enforces privacy. An implementation decides only the choices +//! this module exposes, which are its field policy, its own extensions, its +//! outbound headers and how its responses are read. + +use core::any::Any; +use std::net::IpAddr; +use std::sync::Arc; + +use async_trait::async_trait; +use error_stack::Report; +use http::HeaderMap; +use serde_json::{Map, Value}; +use url::Url; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::platform::PlatformResponse; + +use super::provider::AuctionProvider; +pub use super::routing::{ProviderAuctionInput, ProviderSlotInput, TransportHeaders}; +use super::types::AuctionResponse; + +/// The longest primary `Accept-Language` tag a conservative source receives. +/// +/// A source that sets no limit receives the whole primary tag. +pub const CONSERVATIVE_LANGUAGE_MAX_BYTES: usize = 8; + +/// Where a demand source's timeout comes from when its table sets none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DemandTimeoutDefault { + /// The whole-auction timeout. + Auction, + /// A fixed timeout, in milliseconds. + Fixed(u32), +} + +/// How the `regs` object is derived from the consent the auction admitted. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RegsPolicy { + /// No `regs` without consent data, and GDPR set from the applicability bit + /// or from a GDPR jurisdiction. + #[default] + Jurisdiction, + /// `regs` for any admitted consent, and GDPR set from the applicability bit + /// alone. + ApplicabilityBit, +} + +/// The standard `OpenRTB` field choices one demand source makes. +/// +/// The common driver constructs every standard field and reads this policy +/// while it does. A policy can leave data out of what central privacy +/// enforcement approved, and it cannot add data that enforcement removed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DemandFieldPolicy { + /// Send the slot id as `imp.tagid`. + pub imp_tagid: bool, + /// Copy the first banner format into `banner.w` and `banner.h`, and send + /// `banner.topframe` as not in the top frame. + pub primary_banner_size: bool, + /// Send the accepted `Referer` header as `site.ref`. + pub site_ref: bool, + /// Send precise latitude and longitude with the coarse geo. + pub precise_geo: bool, + /// Send Google Additional Consent as `user.ext.ConsentedProvidersSettings`. + pub additional_consent: bool, + /// The longest primary `Accept-Language` tag sent, or `None` for no limit. + pub language_max_bytes: Option, + /// How `regs` is derived from consent. + pub regs: RegsPolicy, + /// Mark the request as a test request. + pub test: bool, + /// Keep the Trusted Server host and scheme in `ext.trusted_server` when the + /// request is not signed. + pub unsigned_request_identity: bool, + /// Send `Accept: application/json`. + pub accept_json: bool, + /// Treat routed bidder parameters as request data rather than reporting + /// them as parameters the source ignored. + pub consumes_bidder_params: bool, +} + +/// Facts an implementation may use when it adjusts the outbound request. +#[derive(Debug, Clone, Copy)] +pub struct DemandTransport<'a> { + /// The request headers admitted for forwarding. + pub headers: &'a TransportHeaders, + /// The client address the platform attested, never one a client supplied. + pub attested_client_ip: Option, +} + +/// Facts an implementation reads while it parses a response. +#[derive(Clone, Copy)] +pub struct DemandResponse<'a> { + /// The configured name of the demand source. + pub provider_id: &'a str, + /// The canonical endpoint the request was sent to. + pub endpoint: &'a str, + /// The routed input the request was built from. + pub input: &'a ProviderAuctionInput, + /// How long the request took, in milliseconds. + pub response_time_ms: u64, + /// State [`CompiledDemand::capture_request`] returned for this request. + pub captured: Option<&'a (dyn Any + Send + Sync)>, +} + +/// The extension objects an implementation may add to a request the driver +/// built, one for the request and one for each impression. +/// +/// This is the only part of the request an implementation writes. Every +/// standard field is the driver's, chosen through [`DemandFieldPolicy`], so an +/// implementation can add what its exchange needs and cannot change what +/// privacy enforcement decided. The `trusted_server` request extension is the +/// driver's too, and a request that claims it is refused. +pub struct RequestExtensions<'a> { + /// The request-level `ext` object. + pub request: &'a mut Option>, + /// The impressions the driver built, each beside the slot it came from. + pub impressions: Vec>, +} + +/// One impression's `ext` object beside the routed slot it was built from. +pub struct ImpressionExtension<'a> { + /// The routed slot, with its bidder parameters and zone. + pub slot: &'a ProviderSlotInput, + /// The impression's `ext` object. + pub ext: &'a mut Option>, + /// Set by an implementation that has no demand to send for this slot. + /// The driver leaves the impression out of the request, and makes no + /// request at all when every impression is left out. + pub omitted: bool, +} + +/// The request and response behavior of one selected demand source, compiled +/// from its `[demand.]` table at startup. +/// +/// Every method except [`augment_request`](Self::augment_request) and +/// [`parse_response`](Self::parse_response) has a default that suits a source +/// with no special handling. +#[async_trait(?Send)] +pub trait CompiledDemand: Send + Sync { + /// The standard field choices this source makes. + fn field_policy(&self) -> DemandFieldPolicy; + + /// The domain sent as `site.domain` and `site.publisher.domain`. + fn site_domain(&self, publisher_domain: &str) -> String { + publisher_domain.to_owned() + } + + /// The page sent as `site.page`, given the publisher's page and the site + /// domain [`site_domain`](Self::site_domain) chose. + fn site_page(&self, publisher_page: Option<&str>, site_domain: &str) -> Option { + let _ = site_domain; + publisher_page.map(str::to_owned) + } + + /// The consent that may travel in the request body. + fn body_consent<'c>(&self, consent: Option<&'c ConsentContext>) -> Option<&'c ConsentContext> { + consent + } + + /// Adds this source's own request and impression extensions. + /// + /// # Errors + /// + /// Returns an error when an extension cannot be built for this request. + fn augment_request( + &self, + extensions: &mut RequestExtensions<'_>, + input: &ProviderAuctionInput, + ) -> Result<(), Report>; + + /// Adds to the outbound headers before the request is sent, for example to + /// forward admitted headers. The driver has already set `Content-Type`, + /// and `Accept` when the field policy asks for it. + fn prepare_outbound(&self, headers: &mut HeaderMap, transport: DemandTransport<'_>) { + let _ = (headers, transport); + } + + /// Keeps request-local state the response parser needs, taken from the + /// serialized body and the outbound headers as they will be sent, after + /// [`prepare_outbound`](Self::prepare_outbound) has run. + fn capture_request( + &self, + body: &[u8], + headers: &HeaderMap, + ) -> Option> { + let _ = (body, headers); + None + } + + /// Reads the upstream response into an auction response. + /// + /// # Errors + /// + /// Returns an error when the response cannot be read at all. A response + /// that arrived but is not usable is reported as an error response rather + /// than as an `Err`. + async fn parse_response( + &self, + context: DemandResponse<'_>, + response: PlatformResponse, + ) -> Result>; + + /// This compiled source as [`Any`], so the implementation that created it + /// can read its own settings back, for example while it registers routes. + fn as_any(&self) -> &dyn Any; +} + +/// Compiles one demand source from its settings. +/// +/// The map holds the table's own settings, without `implementation` and the +/// common keys the driver reads, which are `endpoint`, `timeout_ms`, `routing` +/// and `notifications`. +pub type CompileDemandFn = + fn(&Map) -> Result, Report>; + +/// Builds an ad server from its configured name and its settings. +/// +/// The map holds the table's own settings without `implementation`, and the +/// name is what the ad server reports itself as in responses and telemetry. +pub type BuildAdServerFn = + fn(&str, &Map) -> Result, Report>; + +/// A demand implementation an integration registers, named by +/// `[demand] modules` or by an `implementation` line. +#[derive(Clone, Copy)] +pub struct DemandImplementation { + /// The implementation's name, its module path. + pub id: &'static str, + /// The timeout a source uses when its table sets none. + pub default_timeout: DemandTimeoutDefault, + /// Whether `routing = "all_eligible"` is allowed. + pub allows_all_eligible: bool, + /// Whether a slot that carries no bidder parameters is still sent, as a + /// stored request, with the slot's zone. + pub serves_stored_requests: bool, + /// Checks and canonicalizes the endpoint after the common checks passed. + pub canonicalize_endpoint: fn(&mut Url) -> Result<(), String>, + /// Compiles one source from its settings. + pub compile: CompileDemandFn, +} + +impl core::fmt::Debug for DemandImplementation { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("DemandImplementation") + .field("id", &self.id) + .field("default_timeout", &self.default_timeout) + .field("allows_all_eligible", &self.allows_all_eligible) + .field("serves_stored_requests", &self.serves_stored_requests) + .finish_non_exhaustive() + } +} + +/// Accepts every endpoint the common checks accepted, unchanged. +/// +/// # Errors +/// +/// Never returns an error. +pub fn accept_endpoint(endpoint: &mut Url) -> Result<(), String> { + let _ = endpoint; + Ok(()) +} + +/// An ad server implementation an integration registers, named by +/// `[ad-server] module` or by an `implementation` line. +#[derive(Clone, Copy)] +pub struct AdServerImplementation { + /// The implementation's name, its module path. + pub id: &'static str, + /// Builds the ad server from its configured name and its settings. + pub build: BuildAdServerFn, +} + +impl core::fmt::Debug for AdServerImplementation { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("AdServerImplementation") + .field("id", &self.id) + .finish_non_exhaustive() + } +} diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f364464561..56d858cb54 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,7 +9,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::COOKIE_TS_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; @@ -171,10 +171,11 @@ pub async fn handle_auction( // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. - // Only forward the EC ID to auction partners when consent allows it. - // Owned so the identity-graph snapshot can be stored back on `ec_context` - // below without holding a borrow of it across the mutation. - let ec_id = if ec_context.ec_allowed() { + // Forward the EC ID to auction partners only when sharing is permitted, + // being storage plus personalized-ad selection, the same pair that gates + // EIDs. Owned so the identity-graph snapshot can be stored back on + // `ec_context` below without holding a borrow of it across the mutation. + let ec_id = if ec_context.ec_sharing_allowed() { ec_context.ec_value().map(str::to_owned) } else { None @@ -211,7 +212,7 @@ pub async fn handle_auction( let empty_result = OrchestrationResult { provider_responses: Vec::new(), - mediator_response: None, + adserver_response: None, winning_bids: HashMap::new(), total_time_ms: 0, metadata: HashMap::new(), @@ -264,7 +265,7 @@ pub async fn handle_auction( let empty_result = OrchestrationResult { provider_responses: Vec::new(), - mediator_response: None, + adserver_response: None, winning_bids: HashMap::new(), total_time_ms: 0, metadata: HashMap::new(), @@ -282,8 +283,9 @@ pub async fn handle_auction( // `ts-eids` cookie so later requests can still forward the browser's // full OpenRTB-style EID structure. // - // Gate this on the same identity-consent condition as the EC ID - // (`ec_id.is_some()`, which is already filtered by `ec_context.ec_allowed()`). + // Gate this on the same identity condition as the EC ID + // (`ec_id.is_some()`, which is already filtered by the sharing pair via + // `ec_context.ec_sharing_allowed()`). // Otherwise a US/GPC or US-Privacy opt-out context — where EC identity use is // denied but a non-personalized auction may still run — could forward // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` @@ -309,8 +311,13 @@ pub async fn handle_auction( // EC and both KV and partner stores are available. Gate the read on a // present registry: without one, `resolve_auction_eids` yields no // server-side EIDs, so the snapshot would be an unused billable KV read. + // The row is read under the owning module's canonical form of the + // identifier, the key it is stored under, rather than under the identifier + // as issued. let auction_kv_snapshot = match (kv, ec_id.as_deref(), registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(_), Some(_)) => ec_context + .ec_kv_key() + .map_or(EcKvSnapshot::NotRead, |kv_key| graph.load_snapshot(&kv_key)), _ => EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response finalization — @@ -325,7 +332,8 @@ pub async fn handle_auction( // Look up geo for device info. let geo = services .geo() - .lookup(services.client_info().client_ip) + .lookup(services.client_info().client_ip, services) + .await .unwrap_or_else(|e| { log::warn!("geo lookup failed: {e}"); None @@ -346,10 +354,9 @@ pub async fn handle_auction( // consent gating before attaching them to the auction request. let merged_eids = merge_auction_eids(client_eids, eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); - auction_request.user.eids = - gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref()); + auction_request.user.eids = gate_eids_by_permissions(merged_eids, ec_context.permissions()); if had_eids && auction_request.user.eids.is_none() { - log::warn!("Auction EIDs stripped by TCF consent gating"); + log::warn!("Auction EIDs stripped: bidstream permissions not set"); } // Create auction context @@ -457,7 +464,13 @@ pub(crate) fn resolve_auction_eids( let ec_id = ec_context.ec_value()?; - let Some(entry) = snapshot.entry_for(ec_id) else { + // Callers read the snapshot under the identity-graph key, the owning + // module's canonical form of the identifier, so the entry is looked up + // under that key rather than under the identifier as issued. + let Some(entry) = ec_context + .kv_key_for(ec_id) + .and_then(|kv_key| snapshot.entry_for(&kv_key)) + else { return Some(Vec::new()); }; @@ -630,6 +643,7 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingModule}; use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; use crate::platform::test_support::{ @@ -677,13 +691,25 @@ mod tests { .build() } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { - EcContext::new_for_test( + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( + ec_value.map(str::to_owned), + ConsentContext::default(), + ec_allowed, + ) + } + + /// A context in a non-regulated jurisdiction with the permission gate open, + /// for tests whose subject is dispatch or identity reuse rather than the + /// jurisdiction gate. + fn make_non_regulated_ec_context(ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( ec_value.map(str::to_owned), ConsentContext { - jurisdiction, + jurisdiction: Jurisdiction::NonRegulated, ..ConsentContext::default() }, + true, ) } @@ -716,9 +742,9 @@ mod tests { let settings = create_test_settings(); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + provider_names: vec!["eid_capturing_provider".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }); orchestrator.register_provider(Arc::new(EidCapturingProvider { @@ -735,7 +761,7 @@ mod tests { graph.create(&ec_id, &live).expect("should seed live row"); lookups.store(0, std::sync::atomic::Ordering::Relaxed); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let mut ec_context = make_non_regulated_ec_context(Some(&ec_id)); let req = Request::builder() .method("POST") .uri("https://test-publisher.com/auction") @@ -785,7 +811,9 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + &crate::platform::test_support::noop_services(), + ) + .await; assert_eq!( lookups.load(std::sync::atomic::Ordering::Relaxed), @@ -803,6 +831,84 @@ mod tests { ); } + #[tokio::test] + async fn auction_endpoint_loads_the_row_under_the_canonical_key() { + // The identity graph stores a row under the owning module's + // canonical form of the identifier. Loaded and resolved under the + // identifier as issued, a module whose canonical form differs from + // the cookie value found no row, so the auction carried no server-side + // EIDs and the context kept a snapshot bound to the wrong key. + let settings = create_test_settings(); + let had_eids = Arc::new(std::sync::Mutex::new(None)); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + provider_names: vec!["eid_capturing_provider".to_string()], + timeout_ms: 2000, + adserver_name: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::clone(&had_eids), + })); + let registry = PartnerRegistry::from_config(&[counting_test_partner("ssp.example.com")]) + .expect("should build partner registry"); + let graph = KvIdentityGraph::in_memory("canonical-auction-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let mut ec_context = make_non_regulated_ec_context(Some(CANONICAL_COOKIE_VALUE)) + .with_module_for_test(Arc::new(CanonicalizingModule)); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider records whether the request carried EIDs and + // then fails its launch, which is all this test needs. The request + // carries no client EIDs, so any EID it records came from the graph. + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the endpoint should load the row stored under the canonical key" + ); + assert_eq!( + *had_eids.lock().expect("should lock captured eids"), + Some(true), + "the auction should carry the canonical row's partner ID as an EID" + ); + } + #[tokio::test] async fn auction_body_eids_reach_kv_even_when_the_ts_eids_cookie_is_absent() { // Regression test for #1184: `/auction` sends every EID it has in the @@ -814,9 +920,9 @@ mod tests { let settings = create_test_settings(); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + provider_names: vec!["eid_capturing_provider".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }); orchestrator.register_provider(Arc::new(EidCapturingProvider { @@ -831,7 +937,15 @@ mod tests { live.consent.ok = true; graph.create(&ec_id, &live).expect("should seed live row"); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let mut ec_context = EcContext::new_for_test_gated( + Some(ec_id.clone()), + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }, + true, + ) + .with_module_for_test(crate::ec::tests::hmac_module()); ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); let req = Request::builder() .method("POST") @@ -883,7 +997,9 @@ mod tests { None, // No `ts-eids` cookie on this request at all. None, &mut response, - ); + &noop_services(), + ) + .await; let (stored, _) = graph .get(&ec_id) @@ -940,7 +1056,7 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for TemplateSwitchProbeProvider { fn provider_name(&self) -> &'static str { - "template-switch-probe" + "template_switch_probe" } async fn request_bids( @@ -992,13 +1108,15 @@ mod tests { #[tokio::test] async fn direct_auction_remains_available_when_templates_are_disabled() { let settings_toml = format!( - "{}\n[auction]\nenabled = true\n\n[auction.providers.template-switch-probe]\nprotocol = \"openrtb-2.6\"\nendpoint = \"https://bidder.example/auction\"\nrouting = \"all_eligible\"\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", - crate_test_settings_str() + "{}\n[demand]\nmodules = [\"template_switch_probe\"]\n\n[demand.template_switch_probe]\nimplementation = \"auction.plain-fixture\"\nendpoint = \"https://bidder.example/auction\"\nrouting = \"all_eligible\"\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\n") ); let settings = Settings::from_toml(&settings_toml) .expect("should parse settings with disabled templates"); let calls = Arc::new(Mutex::new(0)); - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); orchestrator.register_provider(Arc::new(TemplateSwitchProbeProvider { calls: Arc::clone(&calls), })); @@ -1014,7 +1132,11 @@ mod tests { .geo(Arc::new(NoopGeo)) .client_info(ClientInfo::default()) .build(); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let mut ec_context = EcContext::new_for_test_gated(None, consent, true); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1054,16 +1176,16 @@ mod tests { let settings = create_test_settings(); let config = AuctionConfig { enabled: false, - providers: AuctionConfig::legacy_provider_map(&["panic_provider"]), + provider_names: vec!["panic_provider".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(PanicOnBidProvider)); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let mut ec_context = make_non_regulated_ec_context(None); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1114,8 +1236,8 @@ mod tests { #[tokio::test] async fn all_planned_launch_failures_return_bad_gateway_and_execution_failed_telemetry() { let settings_toml = format!( - "{}\n[auction]\nenabled = true\n\n[auction.providers.launch-fail]\nprotocol = \"openrtb-2.6\"\nprofile = \"standard\"\nendpoint = \"https://bidder.example/auction\"\nrouting = \"all_eligible\"\n", - crate_test_settings_str() + "{}\n[demand]\nmodules = [\"launch_fail\"]\n\n[demand.launch_fail]\nimplementation = \"auction.plain-fixture\"\nendpoint = \"https://bidder.example/auction\"\nrouting = \"all_eligible\"\n", + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\n") ); let settings = Settings::from_toml(&settings_toml).expect("should parse launch-failure settings"); @@ -1126,7 +1248,7 @@ mod tests { let orchestrator = AuctionOrchestrator::from_plan(plan, None); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let mut ec_context = make_non_regulated_ec_context(None); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1178,9 +1300,9 @@ mod tests { let settings = create_test_settings(); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["panic_provider"]), + provider_names: vec!["panic_provider".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); @@ -1188,7 +1310,9 @@ mod tests { let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let mut ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + // The default consent context keeps the jurisdiction unknown, so the + // server-side auction gate fails closed; the EC gate is off to match. + let mut ec_context = make_ec_context(false, Some(&ec_id)); let body = json!({ "adUnits": [ @@ -1306,9 +1430,9 @@ mod tests { let settings = create_test_settings(); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + provider_names: vec!["eid_capturing_provider".to_string()], timeout_ms: 2000, - mediator: None, + adserver_name: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); @@ -1385,7 +1509,7 @@ mod tests { fn resolve_auction_eids_returns_empty_without_snapshot() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1397,7 +1521,7 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_without_registry() { let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, None, &ec_context); assert!( @@ -1410,7 +1534,7 @@ mod tests { fn resolve_auction_eids_returns_none_when_consent_denied() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + let ec_context = make_ec_context(false, Some(&ec_id)); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1422,7 +1546,11 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_when_no_ec() { let registry = PartnerRegistry::empty(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1435,7 +1563,7 @@ mod tests { fn resolve_auction_eids_returns_empty_on_kv_miss() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let snapshot = EcKvSnapshot::Failed { ec_id: ec_id.clone(), diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index a0df63d94a..d64caa8a8f 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -481,11 +481,12 @@ pub(crate) fn convert_to_openrtb_response_with_report( delivery.delivered_winner_slots.insert(slot_id.clone()); } - // Determine strategy name for response metadata - let strategy_name = if settings.auction.has_mediator() { - "parallel_mediation" - } else { + // The strategy names what the deployment selected, so it reads the same + // whether or not the ad server answered this auction. + let strategy_name = if settings.adserver.is_empty() { "parallel_only" + } else { + "parallel_adserver" }; // Build per-provider summaries from the orchestration result @@ -554,20 +555,16 @@ pub(crate) fn convert_to_openrtb_response_with_report( #[cfg(test)] mod tests { use super::*; - use crate::auction::plan::{ - AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, - }; + use crate::auction::plan::AuctionPlan; use crate::auction::routing::route_auction; - use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, - }; + use crate::auction::test_support::{demand_table, plan_config}; + use crate::auction::types::{AuctionResponse, Bid, BidRenderer, BidStatus}; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; use http::Method; use serde_json::json; - use std::collections::{BTreeMap, BTreeSet, HashSet}; - use std::str::FromStr as _; + use std::collections::{BTreeSet, HashSet}; fn make_request() -> Request { Request::builder() @@ -582,26 +579,13 @@ mod tests { create_test_settings() } - fn single_prebid_plan() -> AuctionPlan { - AuctionPlan::compile(AuctionPlanConfig { - timeout_ms: 900, - providers: BTreeMap::from([( - ProviderId::from_str("pbs-primary").expect("should parse provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "prebid-server".to_string(), - endpoint: "https://pbs.example.test/openrtb".to_string(), - timeout_ms: None, - routing: RoutingMode::Explicit, - notifications: NotificationConfig::default(), - profile_config: json!({}), - }, - )]), - bidders: BTreeMap::new(), - mediator: None, - request_signing: None, - }) - .expect("should compile plan") + fn single_stored_plan() -> AuctionPlan { + let mut config = plan_config(vec![( + "stored_primary", + demand_table("auction.fixture", "https://stored.example.test/openrtb"), + )]); + config.timeout_ms = 900; + AuctionPlan::compile(config).expect("should compile plan") } fn make_auction_request() -> AuctionRequest { @@ -636,7 +620,7 @@ mod tests { fn make_empty_result() -> OrchestrationResult { OrchestrationResult { provider_responses: Vec::new(), - mediator_response: None, + adserver_response: None, winning_bids: HashMap::new(), total_time_ms: 10, metadata: HashMap::new(), @@ -685,7 +669,7 @@ mod tests { response_time_ms: 42, metadata: HashMap::new(), }], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), total_time_ms: 50, metadata: HashMap::new(), @@ -757,7 +741,7 @@ mod tests { "bids":[{"bidder":"trustedServer","params":{"bidderParams":{},"storedRequest":intent}}]}] })).expect("should deserialize wire request"); let request = convert_body_to_auction_request(&body, &make_settings()); - let routed = route_auction(request, &make_request(), &single_prebid_plan(), None); + let routed = route_auction(request, &make_request(), &single_stored_plan(), None); assert_eq!(routed.inputs().len(), expected_inputs); assert_eq!(routed.diagnostics().malformed_envelope_count(), malformed); } @@ -779,7 +763,7 @@ mod tests { eids: None, }; let request = convert_body_to_auction_request(&body, &make_settings()); - let routed = route_auction(request, &make_request(), &single_prebid_plan(), None); + let routed = route_auction(request, &make_request(), &single_stored_plan(), None); assert_eq!(routed.inputs().len(), 1); assert!( @@ -1503,7 +1487,7 @@ mod tests { response_time_ms: 42, metadata: HashMap::from([("debug".to_string(), debug.clone())]), }], - mediator_response: None, + adserver_response: None, winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), total_time_ms: 50, metadata: HashMap::new(), @@ -1536,24 +1520,25 @@ mod tests { rejected.creative = Some("".to_string()); let unpriced = make_bid("unpriced", "invalid", None); let ordinary = make_bid("ordinary", "appnexus", Some(2.75)); - let mut renderer = make_bid("renderer", "aps", Some(2.5)); + let mut renderer = make_bid("renderer", "example", Some(2.5)); renderer.creative = Some("".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::new( + "example", + json!({ + "version": 1, + "bidId": "upstream-renderer-bid", + "creativeUrl": "https://creative.example/render", + "envelope": "fictional-base64", + }), + ) + .expect("should build the example renderer descriptor"), + ); 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), @@ -1637,18 +1622,19 @@ mod tests { fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() { 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, - })); + let mut bid = make_bid("div-gpt-top", "example", Some(2.75)); + bid.renderer = Some( + BidRenderer::new( + "example", + json!({ + "version": 1, + "bidId": "fictional-bid", + "creativeUrl": "https://creative.example/render", + "envelope": "fictional-base64", + }), + ) + .expect("should build the example renderer descriptor"), + ); let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) @@ -1670,29 +1656,30 @@ mod tests { } #[test] - fn convert_to_openrtb_response_emits_typed_aps_renderer_without_adm() { + fn convert_to_openrtb_response_emits_a_typed_renderer_without_adm() { let settings = make_settings(); let auction_request = make_auction_request(); - let mut bid = make_bid("div-gpt-top", "aps", Some(2.75)); + let mut bid = make_bid("div-gpt-top", "example", Some(2.75)); bid.creative = None; 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::new( + "example", + json!({ + "version": 1, + "bidId": "fictional-bid", + "creativeUrl": "https://creative.example/render", + "envelope": "fictional-base64", + }), + ) + .expect("should build the example renderer descriptor"), + ); let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should convert APS renderer bid"); + .expect("should convert a typed renderer bid"); let json = response_json(response); let bid = json["seatbid"][0]["bid"][0] .as_object() @@ -1703,12 +1690,12 @@ mod tests { "should omit adm for renderer bids" ); assert_eq!(bid["id"], json!("fictional-bid")); - assert_eq!(json["seatbid"][0]["seat"], json!("aps")); + assert_eq!(json["seatbid"][0]["seat"], json!("example")); assert_eq!(bid["adid"], json!("fictional-ad")); assert_eq!(bid["crid"], json!("fictional-creative")); assert_eq!( bid["ext"]["trusted_server"]["renderer"]["type"], - json!("aps") + json!("example") ); assert_eq!( bid["ext"]["trusted_server"]["renderer"]["bidId"], @@ -1744,7 +1731,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(), @@ -1781,7 +1768,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), @@ -1868,9 +1855,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("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))); @@ -1880,8 +1870,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" ); } @@ -2028,8 +2018,11 @@ mod convert_tests { // (empty) applies — verify a key is NOT present rather than IS. // To test the allow-list, inject a key via a custom settings string. let settings_str = format!( - "{}\n[auction]\nallowed_context_keys = [\"permutive_segments\"]\n", - crate_test_settings_str() + "{}\n", + crate_test_settings_str().replace( + "[auction]\n", + "[auction]\nallowed_context_keys = [\"permutive_segments\"]\n" + ) ); let settings = Settings::from_toml(&settings_str).expect("should parse"); let services = noop_services(); diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 4323039284..9cde111aed 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -1,38 +1,42 @@ -//! 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] modules` selects the demand sources and `[ad-server] module` +//! the optional ad server. Their implementations are registered by +//! integrations through [`demand`], so this module names no vendor. 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 openrtb; pub mod orchestrator; pub mod plan; -pub(crate) mod profile; pub mod provider; pub(crate) mod routing; pub mod telemetry; -#[cfg(test)] -pub(crate) mod test_support; +#[cfg(any(test, feature = "test-utils"))] +#[allow( + clippy::must_use_candidate, + clippy::missing_panics_doc, + reason = "test helpers, offered to other crates' tests under the test-utils feature" +)] +pub mod test_support; pub mod types; pub use config::AuctionConfig; pub use context::{ContextQueryParams, ContextValue, build_url_with_context_params}; pub use orchestrator::AuctionOrchestrator; pub use plan::{ - AuctionPlan, BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, + AdServerPlan, AuctionPlan, BidderId, BidderRouteConfig, NotificationConfig, ProviderId, RoutingMode, }; pub use provider::AuctionProvider; @@ -45,66 +49,86 @@ 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]`, `[ad-server]`, 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]`, `[ad-server]`, 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) + .with_auction_token(builders.iter().any(|builder| { + builder.reads_auction_token() + && builder + .module_name() + .is_some_and(|name| settings.selects_module(name)) + })) }) - .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 +139,97 @@ 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 auction_token_tests { + use super::*; + use crate::integrations::registry_test_support::{ + probe_registration, request_fixture, validate_nothing, + }; + use crate::test_support::tests::create_test_settings; + + #[test] + fn the_plan_publishes_a_token_only_where_a_selected_module_reads_one() { + let mut settings = create_test_settings(); + assert!( + !compile_auction_plan(&settings) + .expect("should compile the plan") + .publishes_auction_token(), + "should publish no token where no module is selected" + ); + + settings.select_module("testing", request_fixture::MODULE); + assert!( + compile_auction_plan(&settings) + .expect("should compile the plan") + .publishes_auction_token(), + "should publish a token once a module that reads one is selected" + ); + } + + #[test] + fn a_selected_module_that_reads_no_token_does_not_turn_one_on() { + let mut settings = create_test_settings(); + settings.select_module("testing", "testing.probe"); + let reads_nothing = + [ + IntegrationBuilder::new( + "probe", + "seam-probe", + probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe"), + ]; + assert!( + !compile_auction_plan_with(&settings, &reads_nothing) + .expect("should compile the plan") + .publishes_auction_token(), + "should publish no token for a selected module that reads none" + ); + + let reads_one = [reads_nothing[0].with_auction_token()]; + assert!( + compile_auction_plan_with(&settings, &reads_one) + .expect("should compile the plan") + .publishes_auction_token(), + "should publish a token for a module a vendor crate supplies that reads one" + ); + } } #[cfg(test)] mod plan_sharing_tests { use super::*; + use crate::auction::test_support::demand_named; 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 `[ad-server]` 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 +238,99 @@ 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:?}" + ); + } + + /// The ad server implementation a crate outside core supplies. + static EXTERNAL_ADSERVER: crate::auction::demand::AdServerImplementation = + crate::auction::demand::AdServerImplementation { + id: "ad-server.example", + build: build_external_adserver, + }; + + fn build_external_adserver( + name: &str, + _settings: &Map, + ) -> Result, Report> { + Ok(Arc::new( + crate::auction::test_support::adserver_fixture::FixtureAdServer::new(name, 500), + )) } + /// An ad server a builder outside core supplies resolves in the plan when + /// that builder is passed, and is a name no builder registers when it is + /// not. Both halves are asserted, so the test fails if either stops + /// holding. #[test] - fn configured_mediator_builds_when_exact_registration_is_enabled() { + fn an_ad_server_an_external_builder_supplies_compiles_only_when_it_is_passed() { 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("example", Map::new()); + let extra = [crate::integrations::IntegrationBuilder::implementations( + "example-adserver", + "example-crate", + ) + .with_adserver(&EXTERNAL_ADSERVER)]; + + let error = compile_auction_plan(&settings) + .expect_err("built-ins alone should not know this ad server"); + assert!( + error.to_string().contains("example"), + "should name the ad server: {error:?}" + ); + + let plan = compile_auction_plan_with(&settings, &extra) + .expect("the external builder's ad server should compile"); + assert_eq!( + plan.adserver().map(|adserver| adserver.implementation.id), + Some("ad-server.example"), + "the plan should carry the external ad server" + ); + } + + #[test] + fn the_selected_ad_server_is_built_from_its_own_table() { + let mut settings = create_test_settings(); + settings.adserver = adserver( + "fixture", + 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("fixture", 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( + crate::auction::test_support::plain_fixture::MODULE, + &["provider_a", "provider_b"], + ); let plan = compile_auction_plan(&settings).expect("should compile target-independent plan"); for target in [ @@ -208,26 +347,4 @@ mod plan_sharing_tests { ); } } - - #[test] - fn aps_profile_registers_renderer_without_browser_aps_config() { - let mut settings = create_test_settings(); - settings.auction.providers = std::collections::BTreeMap::from([( - "aps-main".parse().expect("should parse APS provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "aps".to_string(), - endpoint: "https://aps.example/e/pb/bid".to_string(), - timeout_ms: None, - routing: RoutingMode::AllEligible, - notifications: NotificationConfig::default(), - profile_config: serde_json::json!({"account_id":"example-account"}), - }, - )]); - let plan = Arc::new(compile_auction_plan(&settings).expect("should compile APS plan")); - let registry = IntegrationRegistry::with_plan(&settings, plan) - .expect("should build APS renderer registry"); - - assert!(registry.has_route(&http::Method::GET, "/integrations/aps/renderer")); - } } diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index 74553c9bce..44b8087e92 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -1,41 +1,51 @@ -//! 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 error_stack::{Report, ResultExt as _}; +use http::StatusCode; 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, DemandResponse, ImpressionExtension, RegsPolicy, + RequestExtensions, }; +use super::plan::{NotificationPolicy, ProviderPlan}; +use super::routing::{ProviderAuctionInput, ProviderSlotInput, RoutedAuction, TransportHeaders}; use super::types::{AdFormat, AdSlot, AuctionResponse, Bid}; -use crate::consent::ConsentSource; use crate::error::TrustedServerError; use crate::openrtb::{ Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, ToExt as _, TrustedServerExt, User, UserExt, to_openrtb_i32, }; +use crate::platform::PlatformResponse; 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. +/// +/// Public, with the dimension helpers below, so an auction implementation in +/// a crate of its own admits a bid by the rules core's own response reader +/// applies. #[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] -pub(crate) enum BidRejectionReason { +pub enum BidRejectionReason { + /// The bid is malformed. InvalidBid, + /// The bid names an impression the request did not carry. UnrequestedImpression, + /// The bid's dimensions match no format the impression asked for. DimensionMismatch, + /// The bid's dimensions match more than one format the impression asked + /// for. AmbiguousDimensions, } @@ -52,21 +62,21 @@ impl BidRejectionReason { /// Bounded aggregate diagnostics for rejected upstream bids. #[derive(Debug, Default)] -pub(crate) struct ResponseAdmissionDiagnostics { +pub struct ResponseAdmissionDiagnostics { rejected_bid_count: u32, reason_counts: BTreeMap, } impl ResponseAdmissionDiagnostics { /// Record one rejected bid without retaining upstream payload data. - pub(crate) fn record(&mut self, reason: BidRejectionReason) { + pub fn record(&mut self, reason: BidRejectionReason) { self.rejected_bid_count = self.rejected_bid_count.saturating_add(1); let count = self.reason_counts.entry(reason).or_default(); *count = count.saturating_add(1); } /// Attach fixed-cardinality rejection counts to a provider response. - pub(crate) fn attach_to(self, response: &mut AuctionResponse) { + pub fn attach_to(self, response: &mut AuctionResponse) { if self.rejected_bid_count == 0 { return; } @@ -107,7 +117,7 @@ impl DimensionMatch { } /// Precomputed dimension resolutions for one requested impression. -pub(crate) struct SlotBidDimensions { +pub struct SlotBidDimensions { exact: BTreeSet<(u32, u32)>, by_width: BTreeMap, by_height: BTreeMap, @@ -150,17 +160,19 @@ impl SlotBidDimensions { } /// Precomputed requested banner dimensions keyed by impression ID. -pub(crate) type BidDimensionIndex = BTreeMap; +pub type BidDimensionIndex = BTreeMap; /// Build the requested-dimension index once for one provider response. -pub(crate) fn build_bid_dimension_index(input: &ProviderAuctionInput) -> BidDimensionIndex { +#[must_use] +pub fn build_bid_dimension_index(input: &ProviderAuctionInput) -> BidDimensionIndex { build_bid_dimension_index_from_slots(input.slots().iter().map(ProviderSlotInput::slot)) } /// Build the requested-dimension index from plain [`AdSlot`]s. /// /// The first slot wins when several share an ID. -pub(crate) fn build_bid_dimension_index_from_slots<'a>( +#[must_use] +pub fn build_bid_dimension_index_from_slots<'a>( slots: impl IntoIterator, ) -> BidDimensionIndex { let mut index = BidDimensionIndex::new(); @@ -173,7 +185,12 @@ pub(crate) fn build_bid_dimension_index_from_slots<'a>( } /// Parse an optional positive `OpenRTB` bid dimension. -pub(crate) fn parse_optional_bid_dimension( +/// +/// # Errors +/// +/// Returns [`BidRejectionReason::InvalidBid`] when the value is present and is +/// not a positive whole number that fits. +pub fn parse_optional_bid_dimension( value: &Value, key: &str, ) -> Result, BidRejectionReason> { @@ -201,7 +218,12 @@ pub(crate) fn parse_optional_bid_dimension( } /// Validate explicit dimensions or infer them from one matching banner format. -pub(crate) fn resolve_bid_dimensions( +/// +/// # Errors +/// +/// Returns the reason the bid is refused: the impression was not requested, +/// or the dimensions match none or more than one of its formats. +pub fn resolve_bid_dimensions( dimensions_by_slot: &BidDimensionIndex, slot_id: &str, width: Option, @@ -253,12 +275,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, @@ -266,101 +289,90 @@ 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)?; + let omitted = { + // The implementation sees the extension objects and the slot each + // impression came from, and nothing else of the request. + let OpenRtbRequest { ext, imp, .. } = &mut request; + let impressions = imp + .iter_mut() + .zip(&slot_indices) + .map(|(imp, &index)| ImpressionExtension { + slot: &input.slots()[index], + ext: &mut imp.ext, + omitted: false, + }) + .collect(); + let mut extensions = RequestExtensions { + request: ext, + impressions, + }; + demand.augment_request(&mut extensions, input)?; + extensions + .impressions + .iter() + .map(|impression| impression.omitted) + .collect::>() + }; + // An impression the implementation left without demand goes, and so does + // a request that has none left, so a demand source is never asked for + // nothing. + if omitted.iter().any(|&omitted| omitted) { + let mut omitted = omitted.into_iter(); + request.imp.retain(|_| !omitted.next().unwrap_or(false)); + } if request.imp.is_empty() { return Ok(OpenRtbBuildOutcome::NoImpressions); } + if request + .ext + .as_ref() + .is_some_and(|ext| ext.contains_key(TRUSTED_SERVER_EXT_KEY)) + { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "Provider {} set ext.{TRUSTED_SERVER_EXT_KEY}, which only the driver writes", + provider.id + ), + })); + } finalize_request(&mut request, policy, finalization)?; Ok(OpenRtbBuildOutcome::Ready(request)) } -#[derive(Clone, Copy)] -enum ProfilePolicy<'a> { - Standard(&'a StandardProfilePlan), - Prebid(&'a PrebidProfilePlan), - Aps(&'a ApsProfilePlan), -} - -impl<'a> From<&'a CompiledOpenRtbProfile> for ProfilePolicy<'a> { - fn from(profile: &'a CompiledOpenRtbProfile) -> Self { - match profile { - CompiledOpenRtbProfile::Standard(plan) => Self::Standard(plan), - CompiledOpenRtbProfile::PrebidServer(plan) => Self::Prebid(plan), - CompiledOpenRtbProfile::Aps(plan) => Self::Aps(plan), - } - } -} - -impl ProfilePolicy<'_> { - fn augment_request( - self, - request: &mut OpenRtbRequest, - input: &ProviderAuctionInput, - routed: &RoutedAuction, - ) -> Result<(), Report> { - match self { - Self::Standard(plan) => apply_standard(request, plan), - Self::Prebid(plan) => apply_prebid(request, input, routed, plan), - Self::Aps(plan) => apply_aps(request, plan), - } - } - - fn keeps_pbs_identity_when_unsigned(self) -> bool { - matches!(self, Self::Prebid(_)) - } -} - +/// 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()) @@ -374,7 +386,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() @@ -385,8 +397,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() @@ -403,14 +415,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), @@ -420,11 +433,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", @@ -432,10 +442,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 @@ -451,17 +462,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() @@ -472,99 +483,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> { - // Routing admits only slots with positive banner dimensions that fit in i32, - // so build_imp produces one impression per routed slot, in the same order. - debug_assert_eq!( - request.imp.len(), - input.slots().len(), - "should keep one impression per routed slot" - ); - // Filter paired impressions and slots together so later demand keeps its slot ID. - request.imp = std::mem::take(&mut request.imp) - .into_iter() - .zip(input.slots()) - .filter_map(|(mut imp, slot)| { - 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.allows_stored_fallback() { - prebid.insert("storedrequest".to_string(), json!({"id": slot.slot().id})); - } - if prebid.is_empty() { - return None; - } - imp.ext = Some(Map::from_iter([( - "prebid".to_string(), - Value::Object(prebid), - )])); - Some(imp) - }) - .collect(); - 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(|| { @@ -587,7 +508,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, @@ -606,19 +527,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(), @@ -639,8 +560,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() @@ -684,21 +605,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), } } @@ -708,54 +624,8 @@ 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) { +pub fn apply_notification_policy(bids: &mut [Bid], policy: &NotificationPolicy) { for bid in bids { let suppress = policy.suppress_all || bid @@ -897,21 +767,24 @@ 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. +/// +/// Public so an implementation that takes no bidder parameters reports the +/// ones it was routed the way core's own reader does. #[must_use] -pub(crate) fn ignored_bidder_params_count(input: &ProviderAuctionInput) -> u32 { +pub fn ignored_bidder_params_count(input: &ProviderAuctionInput) -> u32 { saturating_bidder_param_counts(input.slots().iter().map(|slot| slot.bidder_params().len())) } @@ -921,20 +794,76 @@ fn saturating_bidder_param_counts(counts: impl IntoIterator) -> u3 }) } +/// The most a demand source's response body may hold. +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + +/// Reads an ordinary `OpenRTB` response. A `204` is a no-bid, any other +/// non-success status or unreadable JSON is an error response, and a body over +/// the size limit is an `Err`. +/// +/// Public so a demand implementation in a crate of its own, whose exchange +/// answers the ordinary way, has its response read as core reads one. +/// +/// # Errors +/// +/// Returns an error when the response body cannot be read. +pub async fn parse_openrtb_response( + context: DemandResponse<'_>, + response: PlatformResponse, +) -> Result> { + let provider_id = context.provider_id; + let response_time_ms = context.response_time_ms; + let response = response.response; + let status = response.status(); + if status == StatusCode::NO_CONTENT { + return Ok(AuctionResponse::no_bid(provider_id, response_time_ms)); + } + if !status.is_success() { + if status.is_redirection() { + log::warn!( + "Provider '{provider_id}' returned a redirect; generic OpenRTB redirects are refused" + ); + } + return Ok(AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("http_status")) + .with_metadata("http_status", json!(status.as_u16()))); + } + + let body = response + .into_body() + .into_bytes_bounded(MAX_RESPONSE_BYTES) + .await + .change_context(TrustedServerError::Auction { + message: format!("Provider {provider_id} response body failed"), + })?; + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(error) => { + log::warn!("Provider '{provider_id}' response JSON was invalid: {error}"); + return Ok(AuctionResponse::error(provider_id, response_time_ms) + .with_metadata("error_type", json!("parse_response"))); + } + }; + + Ok(extract_standard_response( + provider_id, + context.input, + &value, + response_time_ms, + )) +} + #[cfg(test)] mod routing_metadata_tests { use std::collections::BTreeMap; use std::str::FromStr as _; - use 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() { @@ -947,49 +876,32 @@ 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 [("auction.fixture", 0), ("auction.plain-fixture", 1)] { + let endpoint = "https://provider.example/openrtb"; let provider_id = - ProviderId::from_str("fictional-provider").expect("should parse provider ID"); - let 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 { + module: provider_id, + }, + )]); + let plan = AuctionPlan::compile(config).expect("should compile the plan"); let inbound = http::Request::new(edgezero_core::body::Body::empty()); let routed = route_auction(canonical_parity_auction_request(), &inbound, &plan, None); assert_eq!( - unused_bidder_params_count(&plan.providers()[0].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 d322102e62..2f2fa20f84 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 f47efa475e..18eaf5118b 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -9,69 +9,62 @@ 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::ConsentContext; 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"); + // The stand-in forbids all-eligible routing, so a bidder is routed to it. + let routed_by_bidder = implementation == "auction.fixture"; + let mut table = demand_table(implementation, endpoint); + table.insert("timeout_ms".to_string(), json!(321)); + if !routed_by_bidder { + 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 routed_by_bidder { + config.bidders = BTreeMap::from([( + BidderId::from_str("exampleBidder").expect("should parse bidder ID"), + BidderRouteConfig { + module: 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 +80,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 +98,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 +130,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, @@ -172,7 +165,7 @@ fn deterministic_signer() -> RequestSigner { } #[test] -fn consent_matrix_preserves_pbs_standard_and_aps_policies() { +fn consent_matrix_holds_under_each_implementation() { let cases = [ ("empty", ConsentContext::default()), ( @@ -212,26 +205,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 ["auction.plain-fixture", "auction.fixture"] { let mut canonical = canonical_parity_auction_request(); canonical.user.consent = Some(consent.clone()); - let config = if profile == "aps" { - json!({"account_id": "example-account-id"}) - } else { - json!({}) - }; - let value = serde_json::to_value(build_with_request(profile, config, canonical, None)) - .expect("should serialize request"); + let value = serde_json::to_value(build_with_request( + implementation, + json!({}), + canonical, + None, + )) + .expect("should serialize request"); let regs = value.get("regs"); - if profile == "aps" { - let regs = regs.expect("APS should preserve empty admitted context"); - assert_eq!( - regs["gdpr"], - json!(u8::from(consent.gdpr_applies)), - "{name}" - ); - } else if name == "empty" { - assert!(regs.is_none(), "{profile} should omit empty regs"); + if name == "empty" { + 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 +228,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(); @@ -263,53 +249,16 @@ fn consent_matrix_preserves_pbs_standard_and_aps_policies() { } } -#[test] -fn pbs_body_consent_respects_source_and_forwarding_mode() { - for (mode, source, expected) in [ - ("cookies_only", ConsentSource::Cookie, false), - ("cookies_only", ConsentSource::PolicyDefault, true), - ("openrtb_only", ConsentSource::Cookie, true), - ("both", ConsentSource::Cookie, true), - ] { - let mut canonical = canonical_parity_auction_request(); - canonical - .user - .consent - .as_mut() - .expect("should have consent context") - .source = source; - let value = serde_json::to_value(build_with_request( - "prebid-server", - json!({"consent_forwarding": mode}), - canonical, - None, - )) - .expect("should serialize request"); - assert_eq!( - value["user"].get("consent").is_some(), - expected, - "{mode:?} {source:?}" - ); - assert_eq!(value.get("regs").is_some(), expected, "{mode:?} {source:?}"); - } -} - #[test] fn language_limits_are_profile_specific() { let language = "abcdefghijk"; - for (profile, expected) in [ - ("prebid-server", Some(language)), - ("aps", None), - ("standard", None), + for (implementation, expected) in [ + ("auction.fixture", Some(language)), + ("auction.plain-fixture", None), ] { - let config = if profile == "aps" { - json!({"account_id": "example-account-id"}) - } else { - json!({}) - }; let request = build_with_request( - profile, - config, + implementation, + json!({}), canonical_parity_auction_request(), Some(language), ); @@ -318,15 +267,10 @@ fn language_limits_are_profile_specific() { expected ); } - for profile in ["prebid-server", "aps", "standard"] { - let config = if profile == "aps" { - json!({"account_id": "example-account-id"}) - } else { - json!({}) - }; + for implementation in ["auction.fixture", "auction.plain-fixture"] { let request = build_with_request( - profile, - config, + implementation, + json!({}), canonical_parity_auction_request(), Some("en-US,en;q=0.9"), ); @@ -338,272 +282,9 @@ fn language_limits_are_profile_specific() { } #[test] -fn pbs_debug_query_fragment_preserves_exact_legacy_configured_semantics() { - for (page, fragment, expected) in [ - ( - "https://publisher.example/article", - "pbjs_debug=true", - "https://publisher.example/article?pbjs_debug=true", - ), - ( - "https://publisher.example/article?existing=1", - "pbjs_debug=true", - "https://publisher.example/article?existing=1&pbjs_debug=true", - ), - ( - "https://publisher.example/article", - "?pbjs_debug=true", - "https://publisher.example/article??pbjs_debug=true", - ), - ( - "https://publisher.example/article?pbjs_debug=true", - "pbjs_debug=true", - "https://publisher.example/article?pbjs_debug=true", - ), - ( - "https://publisher.example/article", - "", - "https://publisher.example/article", - ), - ] { - let mut request = canonical_parity_auction_request(); - request.publisher.page_url = Some(page.to_string()); - let built = build_with_request( - "prebid-server", - json!({"debug_query_params": fragment}), - request, - None, - ); - assert_eq!( - built.site.and_then(|site| site.page).as_deref(), - Some(expected), - "should preserve exact legacy query fragment semantics" - ); - } -} - -#[test] -fn pbs_routed_overrides_are_ordered_and_stored_request_is_trusted_fallback() { - let mut raw = config( - "prebid-server", - json!({ - "debug": true, - "test_mode": true, - "bid_param_overrides": {"exampleBidder": {"generic": 1, "shared": "generic"}}, - "bid_param_zone_overrides": {"exampleBidder": {"zone-a": {"zone": 2, "shared": "zone"}}}, - "bid_param_override_rules": [ - {"when":{"bidder":"exampleBidder"},"set":{"ordered":1,"shared":"rule-one"}}, - {"when":{"bidder":"exampleBidder","zone":"zone-a"},"set":{"ordered":2,"shared":"rule-two"}} - ] - }), - ); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); - let plan = AuctionPlan::compile(raw).expect("should compile PBS override plan"); - let mut request = canonical_parity_auction_request(); - request.slots[0].bidders = HashMap::from([( - "trustedServer".to_string(), - json!({"zone":"zone-a","bidderParams":{"exampleBidder":{"original":true,"shared":"original"}}}), - )]); - let inbound = Request::builder() - .uri("https://publisher.example/auction") - .body(EdgeBody::empty()) - .expect("should build inbound request"); - let routed = route_auction(request, &inbound, &plan, None); - let built = match build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build request") - { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), - }; - let value = serde_json::to_value(built).expect("should serialize request"); - assert_eq!( - value["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], - json!({"generic":1,"ordered":2,"original":true,"shared":"rule-two","zone":2}) - ); - assert_eq!(value["ext"]["prebid"]["debug"], true); - assert_eq!(value["ext"]["prebid"]["returnallbidstatus"], true); - assert_eq!(value["test"], 1); - - let mut empty_overridden = canonical_parity_auction_request(); - empty_overridden.slots[0].bidders = HashMap::from([( - "trustedServer".to_string(), - json!({"zone":"zone-a","bidderParams":{"exampleBidder":{}}}), - )]); - let routed = route_auction(empty_overridden, &inbound, &plan, None); - let built = match build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build request after populating empty params") - { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => panic!("should retain overridden impression"), - }; - let value = serde_json::to_value(built).expect("should serialize overridden request"); - assert_eq!( - value["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], - json!({"generic":1,"ordered":2,"shared":"rule-two","zone":2}), - "should allow profile overrides to populate empty browser params" - ); - - let mut stored = canonical_parity_auction_request(); - stored.slots[0].bidders.clear(); - let routed = route_auction(stored, &inbound, &plan, None); - let built = match build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build stored request") - { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), - }; - let value = serde_json::to_value(built).expect("should serialize stored request"); - assert_eq!( - value["imp"][0]["ext"]["prebid"]["storedrequest"]["id"], - "fictional-slot" - ); -} - -#[test] -fn pbs_pairs_each_impression_with_its_routed_slot_params() { - let mut raw = config("prebid-server", json!({})); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); - let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); - let mut request = canonical_parity_auction_request(); - request.slots[0].id = "first-slot".to_string(); - request.slots[0].bidders = HashMap::from([( - "trustedServer".to_string(), - json!({"bidderParams":{"exampleBidder":{"placement":"first"}}}), - )]); - let mut second_slot = request.slots[0].clone(); - second_slot.id = "second-slot".to_string(); - second_slot.bidders = HashMap::from([( - "trustedServer".to_string(), - json!({"bidderParams":{"exampleBidder":{"placement":"second"}}}), - )]); - request.slots.push(second_slot); - let inbound = Request::builder() - .uri("https://publisher.example/auction") - .body(EdgeBody::empty()) - .expect("should build inbound request"); - let routed = route_auction(request, &inbound, &plan, None); - - let built = match build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build request") - { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => panic!("should retain impressions"), - }; - let value = serde_json::to_value(built).expect("should serialize request"); - - assert_eq!(value["imp"][0]["id"], "first-slot"); - assert_eq!( - value["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], - json!({"placement":"first"}) - ); - assert_eq!(value["imp"][1]["id"], "second-slot"); - assert_eq!( - value["imp"][1]["ext"]["prebid"]["bidder"]["exampleBidder"], - json!({"placement":"second"}) - ); -} - -#[test] -fn pbs_stored_intent_is_applied_after_overrides_with_inline_first() { - for intent in [None, Some(false), Some(true)] { - for inline in [false, true] { - for fill_override in [false, true] { - let profile = if fill_override { - json!({"bid_param_overrides":{"exampleBidder":{"filled":1}}}) - } else { - json!({}) - }; - let plan = AuctionPlan::compile(config("prebid-server", profile)) - .expect("should compile plan"); - let mut request = canonical_parity_auction_request(); - let mut envelope = json!({"bidderParams":{"exampleBidder":if inline { json!({"original":1}) } else { json!({}) }}}); - if let Some(intent) = intent { - envelope["storedRequest"] = json!(intent); - } - request.slots[0].bidders = HashMap::from([("trustedServer".to_string(), envelope)]); - let inbound = Request::new(EdgeBody::empty()); - let routed = route_auction(request, &inbound, &plan, None); - let result = build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build request"); - if !inline && !fill_override && intent == Some(false) { - assert!(matches!(result, OpenRtbBuildOutcome::NoImpressions)); - continue; - } - let OpenRtbBuildOutcome::Ready(request) = result else { - panic!("should retain demand") - }; - let wire = serde_json::to_value(request).expect("should serialize request"); - let prebid = &wire["imp"][0]["ext"]["prebid"]; - if inline || fill_override { - assert!(prebid.get("storedrequest").is_none()); - assert_eq!( - prebid["bidder"]["exampleBidder"].get("original"), - inline.then_some(&json!(1)) - ); - assert_eq!( - prebid["bidder"]["exampleBidder"].get("filled"), - fill_override.then_some(&json!(1)) - ); - } else { - assert_eq!(prebid["storedrequest"]["id"], "fictional-slot"); - } - } - } - } -} - -#[test] -fn pbs_filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { +fn filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { let plan = - AuctionPlan::compile(config("prebid-server", json!({}))).expect("should compile plan"); + AuctionPlan::compile(config("auction.fixture", json!({}))).expect("should compile plan"); let mut request = canonical_parity_auction_request(); let template = request.slots[0].clone(); request.slots = [ @@ -667,30 +348,23 @@ fn pbs_filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { ); assert_eq!(wire["imp"][0]["id"], "keep-inline"); assert_eq!( - wire["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], + wire["imp"][0]["ext"]["fixture"]["bidder"]["exampleBidder"], json!({"id":2}) ); assert_eq!(wire["imp"][1]["id"], "keep-stored"); - assert_eq!( - wire["imp"][1]["ext"]["prebid"]["storedrequest"]["id"], - "keep-stored" - ); + assert_eq!(wire["imp"][1]["ext"]["fixture"]["stored"], "keep-stored"); } #[test] -fn pbs_disabled_empty_candidate_does_not_become_stored_demand() { - 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; +fn a_disabled_empty_candidate_does_not_become_stored_demand() { + let mut raw = config("auction.fixture", json!({"routing": "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"), + module: ProviderId::from_str("fictional_provider").expect("should parse provider"), }, ); - let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); + let plan = AuctionPlan::compile(raw).expect("should compile the plan"); let mut request = canonical_parity_auction_request(); request.slots[0].bidders = HashMap::from([( "trustedServer".to_string(), @@ -719,183 +393,33 @@ fn pbs_disabled_empty_candidate_does_not_become_stored_demand() { )); } -#[test] -fn pbs_empty_params_without_matching_override_fall_back_to_stored_request() { - let mut raw = config("prebid-server", json!({})); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); - let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); - let mut request = canonical_parity_auction_request(); - request.slots[0].bidders = HashMap::from([( - "trustedServer".to_string(), - json!({"bidderParams":{"exampleBidder":{}}}), - )]); - let inbound = Request::builder() - .uri("https://publisher.example/auction") - .body(EdgeBody::empty()) - .expect("should build inbound request"); - let routed = route_auction(request, &inbound, &plan, None); - let built = match build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build stored request") - { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => panic!("should retain stored impression"), - }; - let value = serde_json::to_value(built).expect("should serialize stored request"); - assert_eq!( - value["imp"][0]["ext"]["prebid"]["storedrequest"]["id"], - "fictional-slot" - ); - assert!(value["imp"][0]["ext"]["prebid"].get("bidder").is_none()); -} - -#[test] -fn pbs_driver_exact_golden_preserves_profile_policy() { - let mut raw = config("prebid-server", json!({"consent_forwarding": "both"})); - raw.providers - .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) - .expect("should find provider") - .routing = RoutingMode::Explicit; - raw.bidders.insert( - crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), - BidderRouteConfig { - provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), - }, - ); - let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); - let mut common = canonical_parity_auction_request(); - common.slots[0].bidders = HashMap::from([( - "exampleBidder".to_string(), - json!({"placement": "fictional-placement"}), - )]); - let inbound = Request::builder() - .uri("https://publisher.example/auction") - .header( - header::REFERER, - "https://referrer.example/story?fictional=1", - ) - .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") - .header("dnt", "1") - .body(EdgeBody::empty()) - .expect("should build inbound request"); - let routed = route_auction(common, &inbound, &plan, None); - let request = match build_request( - &routed.inputs()[0], - &routed, - &plan.providers()[0], - 321, - &finalization(None), - ) - .expect("should build PBS request") - { - OpenRtbBuildOutcome::Ready(request) => request, - OpenRtbBuildOutcome::NoImpressions => panic!("should retain impression"), - }; - assert_eq!( - serde_json::to_string(&request).expect("should serialize PBS driver request"), - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"request_host":"publisher.example","request_scheme":"https"}}}"#, - "should preserve PBS parity differences" - ); -} - -#[test] -fn aps_inventory_identity_and_page_fallback_preserve_legacy_policy() { - let mut request = canonical_parity_auction_request(); - request.publisher.domain = "deployment.example".to_string(); - request.publisher.page_url = - Some("https://deployment.example/news/story?edition=fictional#section".to_string()); - let built = build_with_request( - "aps", - json!({ - "account_id": "example-account-id", - "inventory_domain": "publisher.example", - "inventory_page_origin": "https://www.publisher.example" - }), - request, - None, - ); - let site = built.site.expect("should include APS site"); - assert_eq!(site.domain.as_deref(), Some("publisher.example")); - assert_eq!( - site.page.as_deref(), - Some("https://www.publisher.example/news/story?edition=fictional") - ); - assert_eq!( - site.publisher - .and_then(|publisher| publisher.domain) - .as_deref(), - Some("publisher.example") - ); - - for unsafe_page in [ - "https://user:password@publisher.example/private", - "data:text/html,fictional", - ] { - let mut request = canonical_parity_auction_request(); - request.publisher.page_url = Some(unsafe_page.to_string()); - let built = build_with_request( - "aps", - json!({"account_id":"example-account-id"}), - request, - None, - ); - assert_eq!( - built.site.and_then(|site| site.page).as_deref(), - Some("https://publisher.example"), - "unsafe page should fall back to publisher domain" - ); - } -} - -#[test] -fn aps_driver_exact_golden_preserves_profile_policy() { - let request = build("aps", json!({"account_id": "example-account-id"}), None); - assert_eq!( - serde_json::to_string(&request).expect("should serialize APS driver request"), - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}],"w":300,"h":250,"topframe":0},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"account":"example-account-id","sdk":{"source":"prebid","version":"2.2.0"}}}"#, - "should preserve APS parity differences" - ); -} - #[test] fn signing_finalization_is_after_profiles_and_asserts_every_owned_key() { let signer = deterministic_signer(); - for (profile, config) in [ - ("standard", json!({"request_ext": {"fictional": true}})), - ("prebid-server", json!({})), - ("aps", json!({"account_id": "example-account-id"})), + for (implementation, config) in [ + ( + "auction.plain-fixture", + json!({"request_ext": {"fictional": true}}), + ), + ("auction.fixture", json!({})), ] { - 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 == "auction.fixture" { assert_eq!( unsigned_ts, Some(&json!({"request_host": "publisher.example", "request_scheme": "https"})), - "should retain only PBS host and scheme when unsigned" + "should retain only the host and scheme when unsigned" ); } else { assert!( unsigned_ts.is_none(), - "should omit unsigned non-PBS extension" + "should omit the extension when unsigned" ); } - 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"); @@ -921,35 +445,23 @@ fn signing_finalization_is_after_profiles_and_asserts_every_owned_key() { #[test] fn signed_profiles_and_unsigned_standard_have_exact_full_goldens() { let signer = deterministic_signer(); - let cases = [ - ( - "standard", - json!({"request_ext": {"fictional": true}}), - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"fictional":true,"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, - ), - ( - "prebid-server", - json!({}), - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, - ), - ( - "aps", - json!({"account_id": "example-account-id"}), - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}],"w":300,"h":250,"topframe":0},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"account":"example-account-id","sdk":{"source":"prebid","version":"2.2.0"},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, - ), - ]; - for (profile, config, expected) in cases { + let cases = [( + "auction.plain-fixture", + json!({"request_ext": {"fictional": true}}), + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"bidfloor":1.0,"bidfloorcur":"USD","secure":1}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","publisher":{"domain":"publisher.example"}},"device":{"geo":{"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"fictional":true,"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, + )]; + for (implementation, config, expected) in cases { assert_eq!( - serde_json::to_string(&build(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", + "auction.plain-fixture", json!({"request_ext": {"fictional": true}}), None, )) @@ -959,28 +471,9 @@ 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", - json!({ - "request_ext": {"fictional_request": {"enabled": true}}, - "imp_ext": {"fictional_imp": "value"} - }), - None, - ); - let value = serde_json::to_value(request).expect("should serialize request"); - assert_eq!(value["ext"]["fictional_request"]["enabled"], true); - assert_eq!(value["imp"][0]["ext"]["fictional_imp"], "value"); - assert!( - !value.to_string().contains("exampleBidder"), - "standard profile must not invent bidder params placement" - ); -} - #[test] fn defensive_no_impression_outcome_does_not_build_transportable_request() { - let (plan, mut routed) = routed("standard", json!({})); + let (plan, mut routed) = routed("auction.plain-fixture", json!({})); let mut common = routed.inputs()[0].common_request().clone(); common.slots = vec![AdSlot { id: "video-only".to_string(), @@ -1008,17 +501,13 @@ fn standard_fixture_with_formats( formats: Vec, ) -> (AuctionPlan, RoutedAuction, OpenRtbRequest) { let mut raw = config( - "standard", + "auction.plain-fixture", 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"), + module: ProviderId::from_str("fictional_provider").expect("should parse provider"), }, ); let plan = AuctionPlan::compile(raw).expect("should compile standard fixture plan"); @@ -1056,7 +545,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", @@ -1088,7 +577,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, @@ -1099,14 +588,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"); } @@ -1115,7 +604,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}, @@ -1145,7 +634,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"} @@ -1170,7 +659,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"} @@ -1205,7 +694,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}, @@ -1237,7 +726,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}, @@ -1287,7 +776,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}, @@ -1316,7 +805,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}, @@ -1347,7 +836,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"}]}, @@ -1485,26 +974,29 @@ 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")); }); } #[test] -fn prebid_endpoint_normalization_reaches_generic_execution_and_preserves_custom_paths() { +fn a_completed_endpoint_reaches_generic_execution_and_a_custom_path_is_kept() { futures::executor::block_on(async { for (configured_endpoint, expected_endpoint) in [ ( - "https://pbs.example", - "https://pbs.example/openrtb2/auction", + "https://exchange.example", + "https://exchange.example/fixture/auction", + ), + ( + "https://exchange.example/bid", + "https://exchange.example/bid", ), - ("https://pbs.example/bid", "https://pbs.example/bid"), ] { let plan = AuctionPlan::compile(config_with_endpoint( - "prebid-server", + "auction.fixture", json!({}), configured_endpoint, )) - .expect("should compile Prebid Server endpoint"); + .expect("should compile the endpoint"); let inbound = Request::builder() .uri("https://publisher.example/auction") .body(EdgeBody::empty()) @@ -1531,26 +1023,24 @@ fn prebid_endpoint_normalization_reaches_generic_execution_and_preserves_custom_ &mut reserved_backend_names, ) .await - .expect("should launch one Prebid Server request"); + .expect("should launch one request"); let ProviderRequestOutcome::Pending { request, .. } = outcome else { - panic!("should launch a pending Prebid Server request"); + panic!("should launch a pending request"); }; let selected = services .http_client() .select(vec![request]) .await - .expect("should select one Prebid Server response"); - let response = selected - .ready - .expect("should receive the Prebid Server response"); + .expect("should select one response"); + let response = selected.ready.expect("should receive the response"); assert_eq!(response.response.status(), http::StatusCode::NO_CONTENT); assert_eq!(client.recorded_backend_names(), vec!["stub-backend"]); assert_eq!(client.recorded_request_methods(), vec!["POST"]); assert_eq!(client.recorded_request_uris(), vec![expected_endpoint]); - assert_eq!(provider_plan.backend_spec().host, "pbs.example"); + assert_eq!(provider_plan.backend_spec().host, "exchange.example"); let body: Value = serde_json::from_slice(&client.recorded_request_bodies()[0]) - .expect("should parse recorded Prebid Server body"); + .expect("should parse the recorded body"); assert!( !body .as_object() @@ -1565,7 +1055,97 @@ 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("auction.plain-fixture", 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("auction.fixture", json!({})); + let request = match build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build the request") + { + OpenRtbBuildOutcome::Ready(request) => request, + OpenRtbBuildOutcome::NoImpressions => panic!("should keep the routed impression"), + }; + + let 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("fixture")) + .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 204201e605..37c28e9374 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, @@ -148,9 +148,9 @@ pub(crate) const ERROR_TYPE_TRANSPORT: &str = "transport"; pub(crate) const ERROR_TYPE_TIMEOUT: &str = "timeout"; /// A non-2xx HTTP status from an upstream SSP (e.g. a PBS 4xx/5xx). Distinct /// from [`ERROR_TYPE_TRANSPORT`] (a connection-level failure) so telemetry can -/// bucket it separately. `pub(crate)` so producers such as the prebid provider -/// tag errors with the exact value the telemetry layer recognises. -pub(crate) const ERROR_TYPE_HTTP_STATUS: &str = "http_status"; +/// bucket it separately. Public so a producer in a crate of its own tags +/// errors with the exact value the telemetry layer recognises. +pub const ERROR_TYPE_HTTP_STATUS: &str = "http_status"; /// Every server-owned `error_type` classification. /// @@ -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)] @@ -833,6 +830,21 @@ impl AuctionOrchestrator { Arc::ptr_eq(&self.plan, plan) } + /// Return whether each auction publishes a token with its winning bids. + #[must_use] + pub fn publishes_auction_token(&self) -> bool { + self.plan.publishes_auction_token() + } + + /// The same orchestrator in a deployment where a module reads the + /// auction token. + #[cfg(test)] + #[must_use] + pub(crate) fn publishing_auction_token(mut self) -> Self { + self.plan = Arc::new((*self.plan).clone().with_auction_token(true)); + self + } + /// Register an auction provider in the legacy parity harness. #[cfg(test)] pub(crate) fn register_provider(&mut self, provider: Arc) { @@ -886,7 +898,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 +916,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 +932,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 +943,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 +1077,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 +1108,7 @@ impl AuctionOrchestrator { Ok(OrchestrationResult { provider_responses, - mediator_response: None, + adserver_response: None, winning_bids, total_time_ms: 0, metadata: HashMap::new(), @@ -1113,9 +1127,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 +1630,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 +1662,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 +1873,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 +2099,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 +2306,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 +2343,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 +2379,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 +2423,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 +2434,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 +2462,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 +2472,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 +2504,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 +2515,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 +2534,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 +2548,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,30 +2580,25 @@ impl OrchestrationResult { #[cfg(test)] mod tests { - use std::str::FromStr as _; use std::time::Duration; - use base64::Engine as _; use web_time::Instant; use crate::auction::config::AuctionConfig; use crate::auction::orchestrator::DispatchAuctionOutcome; - use crate::auction::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::platform::test_support::{ - StubHttpClient, build_services_with_backend_and_http_client, + NamingBackend, StubHttpClient, build_services_with_backend_and_http_client, build_services_with_http_client, noop_services, }; use crate::platform::{ @@ -2600,7 +2609,7 @@ mod tests { }; use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; - use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -2609,98 +2618,76 @@ 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")); } - } - - 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, + 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_aps_config() -> AuctionPlanConfig { - planned_aps_instances_config(&[( - "aps-instance", - serde_json::json!({"account_id": "example-account"}), - NotificationConfig::default(), - )]) + fn planned_config(providers: &[(&str, RoutingMode)], signing: bool) -> AuctionPlanConfig { + let tables = providers + .iter() + .map(|(id, routing)| { + ( + *id, + planned_table( + "auction.plain-fixture", + "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_aps_instances_config( + fn planned_fixture_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( + "auction.fixture", + &format!("https://{id}.example.test/openrtb"), + RoutingMode::Explicit, + settings, + notifications, + ), + ) + }) + .collect::>(); + let mut config = plan_config(tables); + config.timeout_ms = 777; + config } fn planned_request() -> AuctionRequest { @@ -2732,7 +2719,7 @@ mod tests { } } - fn planned_prebid_request() -> AuctionRequest { + fn planned_envelope_request() -> AuctionRequest { let mut request = planned_request(); request.slots[0] .bidders @@ -2744,7 +2731,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 +2802,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 +2856,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 +2871,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 +2908,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" @@ -2992,71 +2979,6 @@ mod tests { } } - struct NamingBackend { - policy: BackendNamingPolicy, - predicted: AtomicUsize, - ensured: AtomicUsize, - specs: Mutex>, - fail_ensure_for: Mutex>, - } - - impl NamingBackend { - fn new(policy: BackendNamingPolicy) -> Self { - Self { - policy, - predicted: AtomicUsize::new(0), - ensured: AtomicUsize::new(0), - specs: Mutex::new(Vec::new()), - fail_ensure_for: Mutex::new(HashSet::new()), - } - } - - fn fail_ensure_for(&self, provider_id: &str) { - self.fail_ensure_for - .lock() - .expect("should lock failing provider IDs") - .insert(provider_id.to_string()); - } - - fn name(&self, spec: &PlatformBackendSpec) -> Result> { - self.policy - .predict(spec) - .map(|prediction| prediction.name) - .change_context(PlatformError::Backend) - } - } - - impl PlatformBackend for NamingBackend { - fn naming_policy(&self) -> BackendNamingPolicy { - self.policy - } - - fn predict_name( - &self, - spec: &PlatformBackendSpec, - ) -> Result> { - self.predicted.fetch_add(1, Ordering::Relaxed); - self.name(spec) - } - - fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { - self.ensured.fetch_add(1, Ordering::Relaxed); - if spec.discriminator.as_deref().is_some_and(|provider_id| { - self.fail_ensure_for - .lock() - .expect("should lock failing provider IDs") - .contains(provider_id) - }) { - return Err(Report::new(PlatformError::Backend)); - } - self.specs - .lock() - .expect("should lock planned backend specs") - .push(spec.clone()); - self.name(spec) - } - } - struct ZeroCanonicalBackend { predicted: AtomicUsize, ensured: AtomicUsize, @@ -3452,19 +3374,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 +3399,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 +3408,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 +3420,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 +3430,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 +3449,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 +3463,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 +3641,23 @@ mod tests { } } - /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring - /// `adserver_mock`), while its context-free parse does not. Lets a test prove - /// the synchronous mediation path calls `parse_response_with_context`. - struct CacheRestoringMediator; + /// Ad server whose context-aware parse restores `nurl`/`ad_id`, as one that + /// keeps them out of its request does, while its context-free parse does not. Lets a test prove + /// 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, - }) + let renderer = (bidder == "typed").then(|| { + BidRenderer::new( + "example", + serde_json::json!({ + "version": 1, + "bidId": "typed-selected-bid", + "creativeUrl": "https://creative.example/render", + "envelope": "fictional-base64", + }), + ) + .expect("should build the example renderer descriptor") }); Bid { slot_id: "slot-1".to_string(), @@ -3752,7 +3673,7 @@ mod tests { height: 250, nurl: None, burl: None, - bid_id: (bidder == "aps").then(|| "aps-selected-bid".to_string()), + bid_id: (bidder == "typed").then(|| "typed-selected-bid".to_string()), ad_id: None, creative_id: None, renderer, @@ -3763,14 +3684,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 +3709,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 +3724,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 +3733,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 +3745,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 +3760,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 +3771,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 +3790,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 +3802,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 +3811,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 +3835,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 +3856,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 +3892,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 +3918,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 +3950,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 +3997,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 +4009,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 +4029,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 +4042,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 +4052,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 +4083,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 +4115,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 +4150,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 +4163,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 +4194,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 +4333,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 +4358,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 +4389,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 +4603,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 +4617,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: BTreeSet::from(["permutive_segments".to_string()]), + ..Default::default() }; let orchestrator = AuctionOrchestrator::new(config); @@ -4728,7 +4650,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 +4685,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 +4723,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 +4800,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 +4844,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 +4870,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 +4887,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 +4897,8 @@ mod tests { backend: "bidder-backend", })); orchestrator.register_provider(Arc::new(recording_provider( - "mediator", - "mediator-backend", + "adserver", + "adserver-backend", 2000, &predicted, &requested, @@ -4988,7 +4910,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 +4933,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 +4950,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 +4979,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 +5004,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("fixture".to_string()), + BTreeMap::from([( + "fixture".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 +5047,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 +5063,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 +5087,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 +5101,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 +5130,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 +5156,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 +5203,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 +5235,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 +5285,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 +5332,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 +5369,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 +5390,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 +5470,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 +5536,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 +5601,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" + module: "provider_a" .parse() .expect("should parse fictional provider ID"), }, @@ -5725,7 +5653,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 +5671,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 +5689,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 +5709,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 +5724,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 +5752,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 +5773,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 +5796,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 +5817,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 +5838,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") @@ -5920,10 +5848,11 @@ mod tests { } #[tokio::test] - async fn planned_prebid_stored_intent_filters_wire_demand_and_skips_empty_transports() { + async fn planned_stored_intent_filters_wire_demand_and_skips_empty_transports() { for inline_providers in 0..=2 { let http = Arc::new(StubHttpClient::new()); - // Providers launch in ID order: APS, then only PBS instances with usable demand. + // Providers launch in ID order: the source that takes every slot, then + // only the stored-request sources with usable demand. http.push_response(204, Vec::new()); for index in 0..inline_providers { http.push_response( @@ -5940,7 +5869,7 @@ mod tests { } ]}] })) - .expect("should serialize PBS response"), + .expect("should serialize the upstream response"), ); } let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); @@ -5948,24 +5877,45 @@ mod tests { Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, ); - let mut config = planned_prebid_config(&[ + let mut config = planned_fixture_config(&[ ( - "pbs-a", + "stored_a", serde_json::json!({}), NotificationConfig::default(), ), ( - "pbs-b", + "stored_b", serde_json::json!({}), NotificationConfig::default(), ), ]); - config.providers.extend(planned_aps_config().providers); - for (bidder, provider) in [("alpha", "pbs-a"), ("beta", "pbs-b")] { + let all_eligible = + planned_config(&[("all_eligible_source", RoutingMode::AllEligible)], false); + let mut tables = config.demand.tables().clone(); + tables.extend(all_eligible.demand.tables().clone()); + let mut selected = config + .demand + .selected() + .into_iter() + .map(str::to_owned) + .collect::>(); + selected.extend( + all_eligible + .demand + .selected() + .into_iter() + .map(str::to_owned), + ); + // Sources launch in the order they are selected, and this test reads + // the all-eligible source's request first, so the selection is in + // name order. + selected.sort(); + config.demand = crate::provider_table::ProviderList::new(selected, tables); + for (bidder, provider) in [("alpha", "stored_a"), ("beta", "stored_b")] { config.bidders.insert( bidder.parse().expect("should parse bidder"), crate::auction::plan::BidderRouteConfig { - provider: provider.parse().expect("should parse provider"), + module: provider.parse().expect("should parse provider"), }, ); } @@ -5973,7 +5923,7 @@ mod tests { let orchestrator = AuctionOrchestratorHarness::new(plan, None); let mut request = planned_request(); let template = request.slots[0].clone(); - // Both PBS instances must evaluate candidates but omit them after overrides. + // Both stored-request sources must evaluate candidates and omit them. request.slots[0].bidders.insert( "trustedServer".to_string(), serde_json::json!({ @@ -5981,7 +5931,7 @@ mod tests { }), ); request.slots.push(AdSlot { - id: "synthetic-no-pbs".to_string(), + id: "synthetic-no-demand".to_string(), bidders: HashMap::from([( "trustedServer".to_string(), serde_json::json!({"storedRequest":false,"bidderParams":{}}), @@ -6017,12 +5967,12 @@ mod tests { assert_eq!( bodies.len(), 1 + inline_providers, - "should never transport an empty PBS request" + "should never transport an empty request" ); - let aps: serde_json::Value = - serde_json::from_slice(&bodies[0]).expect("should parse APS wire request"); + let all_eligible_wire: serde_json::Value = serde_json::from_slice(&bodies[0]) + .expect("should parse the all-eligible source's wire request"); assert_eq!( - aps["imp"] + all_eligible_wire["imp"] .as_array() .expect("should have impressions") .len(), @@ -6034,12 +5984,12 @@ mod tests { "should reject a bid for an omitted impression" ); for index in inline_providers..2 { - let provider_id = if index == 0 { "pbs-a" } else { "pbs-b" }; + let provider_id = if index == 0 { "stored_a" } else { "stored_b" }; let response = result .provider_responses .iter() .find(|response| response.provider == provider_id) - .expect("should retain skipped PBS provider response"); + .expect("should retain the skipped source's response"); assert_eq!( response.metadata["routing"]["skipped_no_usable_demand"], true @@ -6047,7 +5997,7 @@ mod tests { } for index in 0..inline_providers { let wire: serde_json::Value = serde_json::from_slice(&bodies[index + 1]) - .expect("should parse PBS wire request"); + .expect("should parse the source's wire request"); assert_eq!( wire["imp"] .as_array() @@ -6056,11 +6006,11 @@ mod tests { 1 ); assert_eq!(wire["imp"][0]["id"], format!("inline-{index}")); - let prebid = &wire["imp"][0]["ext"]["prebid"]; - assert!(prebid.get("storedrequest").is_none()); + let fixture = &wire["imp"][0]["ext"]["fixture"]; + assert!(fixture.get("stored").is_none()); let bidder = if index == 0 { "alpha" } else { "beta" }; assert_eq!( - prebid["bidder"], + fixture["bidder"], serde_json::json!({bidder:{"placement":index}}) ); assert_eq!( @@ -6069,12 +6019,12 @@ mod tests { .as_deref(), Some(format!("bid-{index}").as_str()) ); - let provider_id = if index == 0 { "pbs-a" } else { "pbs-b" }; + let provider_id = if index == 0 { "stored_a" } else { "stored_b" }; let response = result .provider_responses .iter() .find(|response| response.provider == provider_id) - .expect("should retain PBS provider response"); + .expect("should retain the source's response"); assert_eq!( response.metadata["response_admission"]["rejected_bid_count"], 1 @@ -6088,59 +6038,119 @@ mod tests { } #[tokio::test] - async fn planned_prebid_instances_preserve_headers_metadata_suppression_and_identity() { + async fn planned_provider_outcome_matrix_has_fixed_count_only_routing_metadata() { + let standard_plan = AuctionPlan::compile(planned_config( + &[("standard", RoutingMode::AllEligible)], + false, + )) + .expect("should compile standard plan"); + let fixture_plan = AuctionPlan::compile(planned_fixture_config(&[( + "stored", + serde_json::json!({}), + NotificationConfig::default(), + )])) + .expect("should compile the stand-in plan"); + let cases = [ + (&standard_plan, 204, Vec::new(), BidStatus::NoBid), + (&standard_plan, 502, Vec::new(), BidStatus::Error), + (&standard_plan, 200, b"not-json".to_vec(), BidStatus::Error), + ( + &standard_plan, + 200, + br#"{"seatbid":[]}"#.to_vec(), + BidStatus::NoBid, + ), + (&fixture_plan, 204, b"{}".to_vec(), BidStatus::NoBid), + (&fixture_plan, 502, Vec::new(), BidStatus::Error), + (&fixture_plan, 200, b"not-json".to_vec(), BidStatus::Error), + ( + &fixture_plan, + 200, + br#"{"seatbid":[]}"#.to_vec(), + BidStatus::NoBid, + ), + ]; + + for (plan, status, body, expected) in cases { + let routed = route_auction( + planned_envelope_request(), + &http::Request::new(edgezero_core::body::Body::empty()), + plan, + None, + ); + let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let state = provider.parse_state_for_test(routed.inputs()[0].clone()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(status) + .body(edgezero_core::body::Body::from(body)) + .expect("should build provider matrix response"), + ); + let parsed = provider + .parse_response_with_state(response, 4, Some(state.as_ref())) + .await + .expect("should classify provider matrix response"); + assert_eq!(parsed.status, expected, "status {status}"); + if expected == BidStatus::Error { + let expected_error_type = if (200..300).contains(&status) { + "parse_response" + } else { + "http_status" + }; + assert_eq!(parsed.metadata["error_type"], expected_error_type); + } + assert_eq!( + parsed.metadata["routing"], + serde_json::json!({"unused_bidder_params_count": 0}) + ); + let serialized = serde_json::to_string(&parsed.metadata["routing"]) + .expect("should serialize routing metadata"); + assert!(!serialized.contains("fictional_provider")); + assert!(!serialized.contains("fictional-slot")); + } + } + + #[tokio::test] + async fn planned_standard_instances_have_distinct_backends_and_independent_results() { let http = Arc::new(StubHttpClient::new()); http.push_response( 200, serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "suppress-exact", "bid": [ - {"id":"good-a","impid":"fictional-slot","price":1.25,"adm":"
a
","w":300,"h":250,"nurl":"https://notify.example/win","burl":"https://notify.example/bill","ext":{"prebid":{"cache":{"bids":{"cacheId":"cache-a","url":"https://cache-a.example/cache/path"}}}}}, - {"id":"bad-a","price":2.0} - ]}], - "ext": {"responsetimemillis":{"suppress-exact":4},"errors":{"other":["fictional"]},"warnings":{"other":["warning"]},"debug":{"httpcalls":[]},"prebid":{"bidstatus":{"suppress-exact":[{"bidid":"good-a"}]}}} + "seatbid": [{"seat": "fictional-seat-a", "bid": [{ + "id": "bid-a", "impid": "fictional-slot", "price": 1.25, + "adm": "
a
", "w": 300, "h": 250 + }]}] })) - .expect("should serialize PBS response a"), + .expect("should serialize response a"), ); http.push_response( 200, serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "keep-seat", "bid": [{ - "id":"good-b","impid":"fictional-slot","price":2.5,"adm":"
b
","w":300,"h":250,"nurl":"https://notify.example/win","burl":"https://notify.example/bill" + "seatbid": [{"seat": "fictional-seat-b", "bid": [{ + "id": "bid-b", "impid": "fictional-slot", "price": 2.5, + "adm": "
b
", "w": 300, "h": 250 }]}] })) - .expect("should serialize PBS response b"), + .expect("should serialize response b"), ); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); let services = build_services_with_backend_and_http_client( Arc::clone(&backend) as Arc<_>, Arc::clone(&http) as Arc<_>, ); - let notifications = NotificationConfig { - suppress_all: false, - suppress_seats: vec!["suppress-exact".to_string()], - }; - let plan = AuctionPlan::compile(planned_prebid_config(&[ - ( - "pbs-a", - serde_json::json!({"debug":true,"test_mode":true,"consent_forwarding":"openrtb_only"}), - notifications, - ), - ("pbs-b", serde_json::json!({}), NotificationConfig::default()), - ])) - .expect("should compile planned PBS auction"); + let plan = AuctionPlan::compile(planned_config( + &[ + ("provider_a", RoutingMode::AllEligible), + ("provider_b", RoutingMode::AllEligible), + ], + false, + )) + .expect("should compile planned auction"); let orchestrator = AuctionOrchestratorHarness::new(plan, None); - let request = planned_prebid_request(); + let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::builder() .uri("https://publisher.example/auction") - .header( - http::header::COOKIE, - "consent=keep; euconsent-v2=drop; other=value", - ) - .header(http::header::USER_AGENT, "Fictional Browser/7") - .header(http::header::REFERER, "https://referrer.example/story") - .header(http::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") - .header("x-forwarded-for", "203.0.113.250") .body(edgezero_core::body::Body::empty()) .expect("should build inbound request"); let context = AuctionContext { @@ -6155,824 +6165,69 @@ mod tests { let result = orchestrator .run_auction(&request, &context) .await - .expect("should execute planned PBS auction"); + .expect("should execute planned auction"); + assert_eq!(orchestrator.provider_count(), 2); + assert!(orchestrator.adserver().is_none()); assert_eq!(result.provider_responses.len(), 2); - let first = &result.provider_responses[0]; - assert_eq!(first.provider, "pbs-a"); - assert_eq!(first.bids.len(), 1, "should isolate malformed sibling"); + assert_eq!(result.provider_responses[0].provider, "provider_a"); assert_eq!( - first.bids[0].returned_seat.as_deref(), - Some("suppress-exact") + result.provider_responses[0].bids[0].bid_id.as_deref(), + Some("bid-a") ); - assert_eq!(first.bids[0].bidder, "suppress-exact"); - assert!( - first.bids[0].nurl.is_none(), - "should suppress after normalization" + assert_eq!( + result.provider_responses[0].bids[0] + .returned_seat + .as_deref(), + Some("fictional-seat-a") ); - assert!( - first.bids[0].burl.is_none(), - "should suppress billing notification" - ); - assert_eq!(first.bids[0].cache_id.as_deref(), Some("cache-a")); - assert_eq!(first.bids[0].cache_host.as_deref(), Some("cache-a.example")); - assert_eq!(first.bids[0].cache_path.as_deref(), Some("/cache/path")); - assert_eq!(first.metadata["responsetimemillis"]["suppress-exact"], 4); - assert!(first.metadata.contains_key("errors")); - assert!(first.metadata.contains_key("warnings")); - assert!(first.metadata.contains_key("debug")); - assert!(first.metadata.contains_key("bidstatus")); - let second = &result.provider_responses[1]; - assert_eq!(second.provider, "pbs-b"); - assert_eq!(second.bids[0].returned_seat.as_deref(), Some("keep-seat")); - assert!(second.bids[0].nurl.is_some()); - assert!(!second.metadata.contains_key("debug")); - assert!(!second.metadata.contains_key("bidstatus")); - - let headers = http.recorded_request_headers(); - assert_eq!(headers.len(), 2); - for request_headers in &headers { - assert!( - request_headers - .iter() - .any(|(name, value)| name == "user-agent" && value == "Fictional Browser/7") - ); - assert!(request_headers.iter().any( - |(name, value)| name == "referer" && value == "https://referrer.example/story" - )); - assert!( - request_headers - .iter() - .any(|(name, value)| name == "accept-language" && value == "en-US,en;q=0.9") - ); + assert_eq!( + result.provider_responses[0].metadata["routing"]["unused_bidder_params_count"], + 0 + ); + assert_eq!(result.provider_responses[1].provider, "provider_b"); + assert_eq!( + result.provider_responses[1].bids[0].bid_id.as_deref(), + Some("bid-b") + ); + assert_eq!( + result.provider_responses[1].bids[0] + .returned_seat + .as_deref(), + Some("fictional-seat-b") + ); + assert_eq!( + result.provider_responses[1].metadata["routing"]["unused_bidder_params_count"], + 0 + ); + assert_eq!( + result.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("bid-b") + ); + let request_headers = http.recorded_request_headers(); + assert_eq!(request_headers.len(), 2); + for headers in request_headers { assert!( - request_headers + headers .iter() - .all(|(name, _)| name != "x-forwarded-for"), - "must ignore inbound XFF without attestation" + .any(|(name, value)| name == "accept" && value == "application/json"), + "standard planned transport should retain its JSON Accept header" ); + } + let backend_names = http.recorded_backend_names(); + assert_eq!(backend_names.len(), 2); + assert_ne!(backend_names[0], backend_names[1]); + let request_bodies = http.recorded_request_bodies(); + assert_eq!(request_bodies.len(), 2); + for body in request_bodies { + let value: serde_json::Value = + serde_json::from_slice(&body).expect("should parse planned request"); + let tmax = value["tmax"].as_u64().expect("should include logical tmax"); assert!( - request_headers.iter().all(|(name, _)| name != "accept"), - "planned PBS transport must not add Accept beyond legacy headers" + (750..=777).contains(&tmax), + "logical budget should remain near the auction budget, got {tmax}" ); - } - let first_cookie = headers[0] - .iter() - .find(|(name, _)| name == "cookie") - .map(|(_, value)| value.as_str()); - assert_eq!(first_cookie, Some("consent=keep; other=value")); - let second_cookie = headers[1] - .iter() - .find(|(name, _)| name == "cookie") - .map(|(_, value)| value.as_str()); - assert_eq!( - second_cookie, - Some("consent=keep; euconsent-v2=drop; other=value") - ); - } - - #[tokio::test] - async fn planned_aps_mock_mediation_preserves_three_identities_and_renderer() { - let http = Arc::new(StubHttpClient::new()); - http.push_response( - 200, - serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "upstream-seat", "bid": [{ - "id": "aps-bid", "impid": "fictional-slot", "price": 2.0, - "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"} - }]}] - })) - .expect("should serialize APS response"), - ); - http.push_response( - 200, - serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "aps-instance", "bid": [{ - "id": "mediated-aps", "impid": "fictional-slot", "price": 2.0, - "adm": "ignored", "w": 300, "h": 250, "crid": "aps-creative" - }]}] - })) - .expect("should serialize mediator response"), - ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); - let services = build_services_with_backend_and_http_client( - Arc::clone(&backend) as Arc<_>, - Arc::clone(&http) as Arc<_>, - ); - let plan = - AuctionPlan::compile(planned_aps_config()).expect("should compile planned APS auction"); - let mediator = AdServerMockProvider::new(AdServerMockConfig { - enabled: true, - endpoint: "https://mediator.example/mediate".to_string(), - timeout_ms: 500, - ..AdServerMockConfig::default() - }); - let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(mediator))); - let request = planned_request(); - let settings = create_test_settings(); - let inbound = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &inbound, - timeout_ms: 777, - transport_timeout_ms: 777, - provider_responses: None, - services: &services, - }; - - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should mediate planned APS bid"); - - let provider_bid = &result.provider_responses[0].bids[0]; - assert_eq!(result.provider_responses[0].provider, "aps-instance"); - assert_eq!(provider_bid.returned_seat.as_deref(), Some("upstream-seat")); - assert_eq!(provider_bid.bidder, "aps"); - let winner = &result.winning_bids["fictional-slot"]; - assert_eq!(winner.returned_seat.as_deref(), Some("upstream-seat")); - assert_eq!(winner.bidder, "aps"); - assert!(winner.renderer.is_some()); - assert!(winner.creative.is_none()); - assert_eq!( - result - .mediator_response - .as_ref() - .map(|response| response.provider.as_str()), - Some("adserver_mock") - ); - } - - #[tokio::test] - async fn planned_aps_transport_omits_accept_header() { - let http = Arc::new(StubHttpClient::new()); - http.push_response(400, Vec::new()); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); - let services = build_services_with_backend_and_http_client( - Arc::clone(&backend) as Arc<_>, - Arc::clone(&http) as Arc<_>, - ); - let plan = - AuctionPlan::compile(planned_aps_config()).expect("should compile planned APS auction"); - let orchestrator = AuctionOrchestratorHarness::new(plan, None); - let request = planned_request(); - let settings = create_test_settings(); - let inbound = http::Request::builder() - .uri("https://publisher.example/auction") - .body(edgezero_core::body::Body::empty()) - .expect("should build inbound request"); - let context = AuctionContext { - settings: &settings, - request: &inbound, - timeout_ms: 777, - transport_timeout_ms: 777, - provider_responses: None, - services: &services, - }; - - orchestrator - .run_auction(&request, &context) - .await - .expect("should execute planned APS auction"); - - let headers = http.recorded_request_headers(); - assert_eq!(headers.len(), 1); - assert!( - headers[0].iter().all(|(name, _)| name != "accept"), - "planned APS transport must not add Accept beyond legacy headers" - ); - } - - #[tokio::test] - async fn planned_aps_profile_normalizes_renderer_reduction_and_metadata() { - let http = Arc::new(StubHttpClient::new()); - http.push_response_with_headers( - 200, - serde_json::to_vec(&serde_json::json!({ - "cur": "USD", - "seatbid": [ - {"seat": "returned-seat", "bid": [ - {"id": "z-high", "impid": "fictional-slot", "price": 2.0, "w": 300, "h": 250, - "nurl": "https://notice.example/win", "burl": "https://notice.example/bill", - "crid": "fictional-creative", "adomain": ["advertiser.example"], - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, - {"id": "a-high", "impid": "fictional-slot", "price": 2.0, "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, - {"id": "bad-script", "impid": "fictional-slot", "price": 9.0, "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "script"}}, - {"id": "bad-domain", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, - "ext": {"creativeurl": "https://publisher.example/render", "tagtype": "iframe"}}, - {"id": "bad-credentials", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, - "ext": {"creativeurl": "https://user:password@creative.example/render", "tagtype": "iframe"}}, - {"id": "bad-imp", "impid": "unknown-slot", "price": 8.0, "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, - {"id": "bad-dimensions", "impid": "fictional-slot", "price": 8.0, "w": 320, "h": 50, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, - {"id": "bad-price", "impid": "fictional-slot", "price": "high", "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, - {"id": "bad-mtype", "impid": "fictional-slot", "price": 8.0, "mtype": 2, "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, - {"id": "bad-tag", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "native"}}, - {"id": "bad-crid", "impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, - "crid": "x".repeat(1025), - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}}, - {"impid": "fictional-slot", "price": 8.0, "w": 300, "h": 250, - "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"}} - ]}, - {"seat": 7, "bid": "bad-shape"} - ] - })) - .expect("should serialize APS profile response"), - vec![ - ("content-type", "application/json"), - ("authorization", "fictional-secret"), - ], - ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); - let services = build_services_with_backend_and_http_client( - Arc::clone(&backend) as Arc<_>, - Arc::clone(&http) as Arc<_>, - ); - let plan = AuctionPlan::compile(planned_aps_instances_config(&[( - "aps-instance", - serde_json::json!({"account_id": "example-account", "debug": true}), - NotificationConfig { - suppress_all: false, - suppress_seats: vec!["returned-seat".to_string()], - }, - )])) - .expect("should compile planned APS profile"); - let orchestrator = AuctionOrchestratorHarness::new(plan, None); - let request = planned_request(); - let settings = create_test_settings(); - let inbound = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &inbound, - timeout_ms: 777, - transport_timeout_ms: 777, - provider_responses: None, - services: &services, - }; - - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should execute planned APS profile"); - - let response = &result.provider_responses[0]; - assert_eq!(response.provider, "aps-instance"); - assert_eq!(response.status, BidStatus::Success); - assert_eq!( - response.bids.len(), - 1, - "should retain one bid per impression" - ); - let bid = &response.bids[0]; - assert_eq!(bid.bidder, "aps"); - assert_eq!(bid.returned_seat.as_deref(), Some("returned-seat")); - assert_eq!( - bid.bid_id.as_deref(), - Some("a-high"), - "lexical ID should break equal-price tie" - ); - assert!(bid.creative.is_none()); - assert!( - bid.nurl.is_none() && bid.burl.is_none(), - "APS must discard notification URLs" - ); - let renderer = bid - .renderer - .as_ref() - .and_then(BidRenderer::as_aps) - .expect("should construct typed APS renderer"); - assert_eq!(renderer.account_id, "example-account"); - let decoded = base64::engine::general_purpose::STANDARD - .decode(&renderer.aax_response) - .expect("should decode minimized APS response"); - assert_eq!( - serde_json::from_slice::(&decoded) - .expect("should parse minimized APS response"), - serde_json::json!({"seatbid":[{"bid":[{ - "id":"a-high","price":2.0,"w":300,"h":250, - "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} - }]}]}) - ); - assert_eq!(response.metadata["seatbid_count"], 2); - assert_eq!(response.metadata["accepted_bid_count"], 1); - assert_eq!(response.metadata["dropped_bid_count"], 12); - for reason in [ - "lost_to_higher_bid", - "script_rendering_disabled", - "unknown_impid", - "invalid_dimensions", - "invalid_price", - "unsupported_media_type", - "unsupported_tagtype", - "creative_id_too_large", - "missing_render_source", - "empty_seatbid_bids", - ] { - assert_eq!(response.metadata["drop_reasons"][reason], 1, "{reason}"); - } - assert_eq!( - response.metadata["drop_reasons"]["invalid_creative_url"], 2, - "same-publisher and credentialed URLs should both be rejected" - ); - assert_eq!( - response.metadata["routing"]["unused_bidder_params_count"], - 0 - ); - let debug = &response.metadata["debug"]["httpcalls"]["aps"][0]; - assert_eq!(debug["uri"], "https://aps.example/e/pb/bid"); - assert_eq!( - debug["responseheaders"], - serde_json::json!({"content-type": ["application/json"]}), - "async stub should preserve allowlisted response headers" - ); - assert!( - debug["requestbody"] - .as_str() - .is_some_and(|body| body.contains("example-account")) - ); - assert!(debug["requestheaders"].get("authorization").is_none()); - assert!(debug["responseheaders"].get("authorization").is_none()); - } - - #[tokio::test] - async fn two_planned_aps_instances_correlate_independently() { - let http = Arc::new(StubHttpClient::new()); - for (seat, id, price) in [("seat-a", "bid-a", 1.0), ("seat-b", "bid-b", 2.0)] { - http.push_response( - 200, - serde_json::to_vec(&serde_json::json!({"seatbid":[{"seat":seat,"bid":[{ - "id":id,"impid":"fictional-slot","price":price,"w":300,"h":250, - "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} - }]}]})) - .expect("should serialize APS instance response"), - ); - } - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); - let services = build_services_with_backend_and_http_client( - Arc::clone(&backend) as Arc<_>, - Arc::clone(&http) as Arc<_>, - ); - let plan = AuctionPlan::compile(planned_aps_instances_config(&[ - ( - "aps-a", - serde_json::json!({"account_id":"account-a"}), - NotificationConfig::default(), - ), - ( - "aps-b", - serde_json::json!({"account_id":"account-b"}), - NotificationConfig::default(), - ), - ])) - .expect("should compile two APS instances"); - let orchestrator = AuctionOrchestratorHarness::new(plan, None); - let request = planned_request(); - let settings = create_test_settings(); - let inbound = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &inbound, - timeout_ms: 777, - transport_timeout_ms: 777, - provider_responses: None, - services: &services, - }; - - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should execute two APS instances"); - - assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "aps-a"); - assert_eq!( - result.provider_responses[0].bids[0].bid_id.as_deref(), - Some("bid-a") - ); - assert_eq!(result.provider_responses[1].provider, "aps-b"); - assert_eq!( - result.provider_responses[1].bids[0].bid_id.as_deref(), - Some("bid-b") - ); - assert_eq!(http.recorded_request_bodies().len(), 2); - assert_eq!( - result.winning_bids["fictional-slot"].bid_id.as_deref(), - Some("bid-b"), - "global ranking should remain orchestrator-owned" - ); - let specs = backend.specs.lock().expect("should lock specs"); - assert_eq!(specs.len(), 2); - assert_ne!(specs[0].discriminator, specs[1].discriminator); - } - - #[tokio::test] - async fn planned_aps_returned_seat_accepts_only_valid_nonempty_strings() { - let plan = AuctionPlan::compile(planned_aps_config()).expect("should compile APS plan"); - let routed = route_auction( - planned_request(), - &http::Request::new(edgezero_core::body::Body::empty()), - &plan, - None, - ); - let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); - for (seat, expected) in [ - (serde_json::Value::Null, None), - (serde_json::json!(7), None), - (serde_json::json!(""), None), - (serde_json::json!("exact-seat"), Some("exact-seat")), - ] { - let state = provider.parse_state_for_test(routed.inputs()[0].clone()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(200) - .body(edgezero_core::body::Body::from( - serde_json::to_vec(&serde_json::json!({"seatbid":[{"seat":seat,"bid":[{ - "id":"bid","impid":"fictional-slot","price":1.0,"w":300,"h":250, - "nurl":"https://notice.example/win","burl":"https://notice.example/bill", - "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} - }]}]})) - .expect("should serialize seat identity response"), - )) - .expect("should build seat identity response"), - ); - let parsed = provider - .parse_response_with_state(response, 4, Some(state.as_ref())) - .await - .expect("should parse seat identity response"); - assert_eq!(parsed.bids[0].returned_seat.as_deref(), expected); - assert!(parsed.bids[0].nurl.is_none() && parsed.bids[0].burl.is_none()); - } - } - - #[tokio::test] - async fn planned_aps_response_status_shape_and_currency_matrix() { - let plan = AuctionPlan::compile(planned_aps_config()).expect("should compile APS plan"); - let routed = route_auction( - planned_request(), - &http::Request::new(edgezero_core::body::Body::empty()), - &plan, - None, - ); - let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); - let cases = [ - (204, Vec::new(), BidStatus::NoBid, None, None), - (400, Vec::new(), BidStatus::Error, None, Some("http_status")), - ( - 200, - b"not-json".to_vec(), - BidStatus::Error, - Some("unexpected_response_shape"), - Some("parse_response"), - ), - ( - 200, - b"[]".to_vec(), - BidStatus::Error, - Some("unexpected_response_shape"), - Some("parse_response"), - ), - ( - 200, - br#"{"contextual":true}"#.to_vec(), - BidStatus::Error, - Some("unexpected_response_shape"), - Some("parse_response"), - ), - ( - 200, - br#"{"cur":"EUR","seatbid":[]}"#.to_vec(), - BidStatus::NoBid, - Some("unsupported_currency"), - None, - ), - ]; - for (status, body, expected, reason, error_type) in cases { - let state = provider.parse_state_for_test(routed.inputs()[0].clone()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(status) - .body(edgezero_core::body::Body::from(body)) - .expect("should build APS matrix response"), - ); - let parsed = provider - .parse_response_with_state(response, 4, Some(state.as_ref())) - .await - .expect("should classify APS matrix response"); - assert_eq!(parsed.status, expected, "status {status}"); - if let Some(reason) = reason { - assert_eq!( - parsed.metadata["drop_reasons"][reason], 1, - "status {status}" - ); - } - if let Some(error_type) = error_type { - assert_eq!(parsed.metadata["error_type"], error_type, "status {status}"); - } - } - } - - #[tokio::test] - async fn planned_provider_outcome_matrix_has_fixed_count_only_routing_metadata() { - let standard_plan = AuctionPlan::compile(planned_config( - &[("standard", RoutingMode::AllEligible)], - false, - )) - .expect("should compile standard plan"); - let prebid_plan = AuctionPlan::compile(planned_prebid_config(&[( - "pbs", - serde_json::json!({}), - NotificationConfig::default(), - )])) - .expect("should compile PBS plan"); - let cases = [ - (&standard_plan, 204, Vec::new(), BidStatus::NoBid), - (&standard_plan, 502, Vec::new(), BidStatus::Error), - (&standard_plan, 200, b"not-json".to_vec(), BidStatus::Error), - ( - &standard_plan, - 200, - br#"{"seatbid":[]}"#.to_vec(), - BidStatus::NoBid, - ), - (&prebid_plan, 204, b"{}".to_vec(), BidStatus::NoBid), - (&prebid_plan, 502, Vec::new(), BidStatus::Error), - (&prebid_plan, 200, b"not-json".to_vec(), BidStatus::Error), - ( - &prebid_plan, - 200, - br#"{"seatbid":[]}"#.to_vec(), - BidStatus::NoBid, - ), - ]; - - for (plan, status, body, expected) in cases { - let routed = route_auction( - planned_prebid_request(), - &http::Request::new(edgezero_core::body::Body::empty()), - plan, - None, - ); - let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); - let state = provider.parse_state_for_test(routed.inputs()[0].clone()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(status) - .body(edgezero_core::body::Body::from(body)) - .expect("should build provider matrix response"), - ); - let parsed = provider - .parse_response_with_state(response, 4, Some(state.as_ref())) - .await - .expect("should classify provider matrix response"); - assert_eq!(parsed.status, expected, "status {status}"); - if expected == BidStatus::Error { - let expected_error_type = if (200..300).contains(&status) { - "parse_response" - } else { - "http_status" - }; - assert_eq!(parsed.metadata["error_type"], expected_error_type); - } - assert_eq!( - parsed.metadata["routing"], - serde_json::json!({"unused_bidder_params_count": 0}) - ); - let serialized = serde_json::to_string(&parsed.metadata["routing"]) - .expect("should serialize routing metadata"); - assert!(!serialized.contains("fictional-provider")); - assert!(!serialized.contains("fictional-slot")); - } - } - - #[tokio::test] - async fn planned_aps_script_opt_in_matches_shared_renderer_fixture() { - let plan = AuctionPlan::compile(planned_aps_instances_config(&[( - "aps-instance", - serde_json::json!({ - "account_id":"example-account-id", - "allow_script_creatives":true - }), - NotificationConfig::default(), - )])) - .expect("should compile script-enabled APS plan"); - let routed = route_auction( - planned_request(), - &http::Request::new(edgezero_core::body::Body::empty()), - &plan, - None, - ); - let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); - let state = provider.parse_state_for_test(routed.inputs()[0].clone()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(200) - .body(edgezero_core::body::Body::from( - serde_json::to_vec(&serde_json::json!({"seatbid":[{"bid":[{ - "id":"fictional-selected-bid-id","impid":"fictional-slot","price":1.23, - "w":300,"h":250,"crid":"fictional-creative", - "ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"} - },{ - "id":"script-bid","impid":"fictional-slot","price":1.0, - "w":300,"h":250, - "ext":{"creativeurl":"https://creative.example/script","tagtype":"script"} - }]}]})) - .expect("should serialize APS renderer fixture response"), - )) - .expect("should build APS renderer fixture response"), - ); - - let parsed = provider - .parse_response_with_state(response, 3, Some(state.as_ref())) - .await - .expect("should parse APS renderer fixture response"); - - assert_eq!(parsed.status, BidStatus::Success); - assert_eq!( - parsed.metadata["drop_reasons"]["lost_to_higher_bid"], 1, - "enabled script creative should be eligible before reduction" - ); - let renderer = parsed.bids[0] - .renderer - .as_ref() - .and_then(BidRenderer::as_aps) - .expect("should construct APS renderer"); - let decoded = base64::engine::general_purpose::STANDARD - .decode(&renderer.aax_response) - .expect("should decode APS fixture envelope"); - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" - )) - .expect("should parse shared APS renderer fixture"); - assert_eq!( - serde_json::from_slice::(&decoded) - .expect("should parse decoded APS renderer"), - fixture - ); - } - - #[tokio::test] - async fn planned_aps_debug_response_headers_are_allowlisted() { - let plan = AuctionPlan::compile(planned_aps_instances_config(&[( - "aps-instance", - serde_json::json!({"account_id":"example-account","debug":true}), - NotificationConfig::default(), - )])) - .expect("should compile debug APS plan"); - let routed = route_auction( - planned_request(), - &http::Request::new(edgezero_core::body::Body::empty()), - &plan, - None, - ); - let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); - let state = provider.parse_state_for_test(routed.inputs()[0].clone()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(200) - .header("content-type", "application/json") - .header("authorization", "fictional-secret") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build debug APS response"), - ); - - let parsed = provider - .parse_response_with_state(response, 3, Some(state.as_ref())) - .await - .expect("should parse debug APS response"); - - let headers = &parsed.metadata["debug"]["httpcalls"]["aps"][0]["responseheaders"]; - assert_eq!( - headers, - &serde_json::json!({"content-type":["application/json"]}) - ); - assert!(headers.get("authorization").is_none()); - } - - #[tokio::test] - async fn planned_standard_instances_have_distinct_backends_and_independent_results() { - let http = Arc::new(StubHttpClient::new()); - http.push_response( - 200, - serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "fictional-seat-a", "bid": [{ - "id": "bid-a", "impid": "fictional-slot", "price": 1.25, - "adm": "
a
", "w": 300, "h": 250 - }]}] - })) - .expect("should serialize response a"), - ); - http.push_response( - 200, - serde_json::to_vec(&serde_json::json!({ - "seatbid": [{"seat": "fictional-seat-b", "bid": [{ - "id": "bid-b", "impid": "fictional-slot", "price": 2.5, - "adm": "
b
", "w": 300, "h": 250 - }]}] - })) - .expect("should serialize response b"), - ); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); - let services = build_services_with_backend_and_http_client( - Arc::clone(&backend) as Arc<_>, - Arc::clone(&http) as Arc<_>, - ); - let plan = AuctionPlan::compile(planned_config( - &[ - ("provider-a", RoutingMode::AllEligible), - ("provider-b", RoutingMode::AllEligible), - ], - false, - )) - .expect("should compile planned auction"); - let orchestrator = AuctionOrchestratorHarness::new(plan, None); - let request = planned_request(); - let settings = create_test_settings(); - let inbound = http::Request::builder() - .uri("https://publisher.example/auction") - .body(edgezero_core::body::Body::empty()) - .expect("should build inbound request"); - let context = AuctionContext { - settings: &settings, - request: &inbound, - timeout_ms: 777, - transport_timeout_ms: 777, - provider_responses: None, - services: &services, - }; - - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should execute planned auction"); - - assert_eq!(orchestrator.provider_count(), 2); - assert!(orchestrator.mediator().is_none()); - assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "provider-a"); - assert_eq!( - result.provider_responses[0].bids[0].bid_id.as_deref(), - Some("bid-a") - ); - assert_eq!( - result.provider_responses[0].bids[0] - .returned_seat - .as_deref(), - Some("fictional-seat-a") - ); - assert_eq!( - result.provider_responses[0].metadata["routing"]["unused_bidder_params_count"], - 0 - ); - assert_eq!(result.provider_responses[1].provider, "provider-b"); - assert_eq!( - result.provider_responses[1].bids[0].bid_id.as_deref(), - Some("bid-b") - ); - assert_eq!( - result.provider_responses[1].bids[0] - .returned_seat - .as_deref(), - Some("fictional-seat-b") - ); - assert_eq!( - result.provider_responses[1].metadata["routing"]["unused_bidder_params_count"], - 0 - ); - assert_eq!( - result.winning_bids["fictional-slot"].bid_id.as_deref(), - Some("bid-b") - ); - let request_headers = http.recorded_request_headers(); - assert_eq!(request_headers.len(), 2); - for headers in request_headers { - assert!( - headers - .iter() - .any(|(name, value)| name == "accept" && value == "application/json"), - "standard planned transport should retain its JSON Accept header" - ); - } - let backend_names = http.recorded_backend_names(); - assert_eq!(backend_names.len(), 2); - assert_ne!(backend_names[0], backend_names[1]); - let request_bodies = http.recorded_request_bodies(); - assert_eq!(request_bodies.len(), 2); - for body in request_bodies { - let value: serde_json::Value = - serde_json::from_slice(&body).expect("should parse planned request"); - let tmax = value["tmax"].as_u64().expect("should include logical tmax"); - assert!( - (750..=777).contains(&tmax), - "logical budget should remain near the auction budget, got {tmax}" - ); - assert_eq!(value["imp"].as_array().map(Vec::len), Some(1)); + assert_eq!(value["imp"].as_array().map(Vec::len), Some(1)); } let specs = backend.specs.lock().expect("should lock specs"); assert_eq!(specs.len(), 2); @@ -7001,8 +6256,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, )) @@ -7026,12 +6281,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" @@ -7051,8 +6306,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, )) @@ -7076,12 +6331,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); } @@ -7098,8 +6353,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, )) @@ -7123,12 +6378,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); } @@ -7136,8 +6391,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, )) @@ -7164,28 +6419,28 @@ 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" ); } #[tokio::test] - async fn planned_prebid_rejects_cross_provider_parse_state() { - let plan = AuctionPlan::compile(planned_prebid_config(&[ + async fn planned_source_rejects_cross_provider_parse_state() { + let plan = AuctionPlan::compile(planned_fixture_config(&[ ( - "pbs-a", + "stored_a", serde_json::json!({}), NotificationConfig::default(), ), ( - "pbs-b", + "stored_b", serde_json::json!({}), NotificationConfig::default(), ), ])) - .expect("should compile planned PBS auction"); + .expect("should compile the planned auction"); let routed = route_auction( - planned_prebid_request(), + planned_envelope_request(), &http::Request::new(edgezero_core::body::Body::empty()), &plan, None, @@ -7197,17 +6452,17 @@ mod tests { edgezero_core::http::response_builder() .status(200) .body(edgezero_core::body::Body::from_bytes(b"{}".as_slice())) - .expect("should build PBS response"), + .expect("should build the upstream response"), ); let error = provider_b .parse_response_with_state(response, 1, Some(parse_state.as_ref())) .await - .expect_err("should reject another PBS provider's parse state"); + .expect_err("should reject another source's parse state"); assert!( - error.to_string().contains("owned by provider pbs-a"), - "should identify cross-provider PBS state ownership" + error.to_string().contains("owned by provider stored_a"), + "should identify cross-provider state ownership" ); } @@ -7274,18 +6529,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("fixture".to_string()), + BTreeMap::from([( + "fixture".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()); @@ -7323,15 +6587,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() ); } @@ -7340,12 +6604,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 }, { @@ -7371,18 +6637,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, )) @@ -7415,7 +6681,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::>() @@ -7423,7 +6689,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::>() @@ -7434,10 +6700,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::>() @@ -7455,7 +6721,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") @@ -7467,62 +6733,57 @@ 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")] { - let http = Arc::new(StubHttpClient::new()); - http.push_response(200, vec![b'x'; 1024 * 1024 + 1]); - let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); - let services = build_services_with_backend_and_http_client( - Arc::clone(&backend) as Arc<_>, - Arc::clone(&http) as Arc<_>, - ); - let mut config = if profile == "standard" { - planned_config(&[(provider_id, RoutingMode::AllEligible)], false) - } else { - planned_aps_config() - }; - config.bidders.insert( - "example-bidder" + async fn planned_routing_count_survives_a_bounded_body_failure() { + let (profile, provider_id) = ("standard", "standard"); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, vec![b'x'; 1024 * 1024 + 1]); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let mut config = planned_config(&[(provider_id, RoutingMode::AllEligible)], false); + config.bidders.insert( + "example-bidder" + .parse() + .expect("should parse fictional bidder ID"), + crate::auction::plan::BidderRouteConfig { + module: provider_id .parse() - .expect("should parse fictional bidder ID"), - crate::auction::plan::BidderRouteConfig { - provider: provider_id - .parse() - .expect("should parse fictional provider ID"), - }, - ); - let plan = AuctionPlan::compile(config).expect("should compile bounded-body plan"); - let orchestrator = AuctionOrchestratorHarness::new(plan, None); - let mut request = planned_request(); - request.slots[0].bidders.insert( - "example-bidder".to_string(), - serde_json::json!({"private": "value"}), - ); - let settings = create_test_settings(); - let inbound = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &inbound, - timeout_ms: 777, - transport_timeout_ms: 777, - provider_responses: None, - services: &services, - }; + .expect("should parse fictional provider ID"), + }, + ); + let plan = AuctionPlan::compile(config).expect("should compile bounded-body plan"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let mut request = planned_request(); + request.slots[0].bidders.insert( + "example-bidder".to_string(), + serde_json::json!({"private": "value"}), + ); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should materialize bounded-body failure"); - let response = &result.provider_responses[0]; - assert_eq!(response.status, BidStatus::Error, "{profile}"); - assert_eq!( - response.metadata["routing"]["unused_bidder_params_count"], 1, - "{profile} bounded-body failure should retain the input-derived count" - ); - let routing = serde_json::to_string(&response.metadata["routing"]) - .expect("should serialize routing metadata"); - assert!(!routing.contains("example-bidder") && !routing.contains("private")); - } + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should materialize bounded-body failure"); + let response = &result.provider_responses[0]; + assert_eq!(response.status, BidStatus::Error, "{profile}"); + assert_eq!( + response.metadata["routing"]["unused_bidder_params_count"], 1, + "{profile} bounded-body failure should retain the input-derived count" + ); + let routing = serde_json::to_string(&response.metadata["routing"]) + .expect("should serialize routing metadata"); + assert!(!routing.contains("example-bidder") && !routing.contains("private")); } #[tokio::test] @@ -7620,8 +6881,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, )) @@ -7684,7 +6945,7 @@ mod tests { .parse() .expect("should parse fictional bidder ID"), crate::auction::plan::BidderRouteConfig { - provider: "signed" + module: "signed" .parse() .expect("should parse fictional provider ID"), }, @@ -7828,11 +7089,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, })), ); @@ -7868,9 +7129,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" ); } @@ -7885,8 +7146,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, )) @@ -7935,26 +7196,28 @@ mod tests { } #[test] - fn decoded_aps_bid_competes_directly_by_cpm() { + fn a_bid_with_a_typed_renderer_competes_directly_by_cpm() { let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); let floor_prices = HashMap::new(); let response = |provider: &str, bid: Bid| AuctionResponse::success(provider, vec![bid], 1); - let aps_wins = orchestrator.select_winning_bids( + let typed_wins = orchestrator.select_winning_bids( &[ - response("aps", auction_bid("aps", 2.0)), + response("typed", auction_bid("typed", 2.0)), response("ordinary", auction_bid("ordinary", 1.0)), ], &floor_prices, ); - let winner = aps_wins.get("slot-1").expect("should select APS bid"); - assert_eq!(winner.bidder, "aps"); + let winner = typed_wins + .get("slot-1") + .expect("should select the typed renderer bid"); + assert_eq!(winner.bidder, "typed"); assert!(winner.renderer.is_some()); assert!(winner.creative.is_none()); let ordinary_wins = orchestrator.select_winning_bids( &[ - response("aps", auction_bid("aps", 2.0)), + response("typed", auction_bid("typed", 2.0)), response("ordinary", auction_bid("ordinary", 3.0)), ], &floor_prices, diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs index df36fc09b7..ab9437177e 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]`, `[ad-server]` 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] modules` or +/// `[ad-server] module`. #[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. - pub provider: ProviderId, + /// The `[demand]` module the bidder is sent to. + pub module: 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 `[ad-server]` 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,8 @@ pub struct AuctionPlan { providers: Vec, bidder_routes: BTreeMap, signing_enabled: bool, - mediator: Option, + adserver: Option, + publishes_auction_token: bool, } impl AuctionPlan { @@ -269,77 +293,115 @@ impl AuctionPlan { self.enabled } - pub(crate) fn with_enabled(mut self, enabled: bool) -> Self { + /// Return the plan with auction execution switched on or off, which is how + /// `[auction] enabled` is applied to a compiled plan. + #[must_use] + pub fn with_enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; self } + /// Return whether each auction publishes a token with its winning bids, + /// which it does when a module that runs reads one. + #[must_use] + pub fn publishes_auction_token(&self) -> bool { + self.publishes_auction_token + } + + pub(crate) fn with_auction_token(mut self, publishes: bool) -> Self { + self.publishes_auction_token = publishes; + self + } + /// Compile a deterministic plan without adapter-specific validation. /// /// # Errors /// - /// Returns a configuration error for invalid identifiers, protocol/profile - /// declarations, endpoints, profile configuration, routes, notifications, - /// signing structure, or mediator selection. + /// Returns a configuration error for a table no selector names, a label + /// 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("ad-server") + .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 = resolve_implementation( + "demand", + name, + config.demand.implementation_of(name), + &config.demand_implementations, + |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 { @@ -349,23 +411,59 @@ impl AuctionPlan { } let provider_index = provider_indices - .get(&route.provider) + .get(&route.module) .copied() .ok_or_else(|| { configuration_error(format!( - "bidder `{bidder}` references unknown provider `{}`", - route.provider + "[auction.bidders.{bidder}] sends the bidder to `{}`, which [demand] modules does not select", + route.module )) })?; 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 = resolve_implementation( + "ad-server", + name, + config.adserver.implementation_of(name), + &config.adserver_implementations, + |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!("[ad-server.{name}] endpoint must be a URL")) + })?; + check_endpoint("ad-server", name, endpoint)?; + } + (implementation.build)(name, &settings).change_context( + TrustedServerError::Configuration { + message: format!( + "[ad-server.{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, + publishes_auction_token: false, }) } } @@ -470,38 +568,39 @@ impl AuctionPlan { Ok(()) } - /// Borrow compiled providers in deterministic provider-ID order. + /// Borrow compiled demand sources in the order `[demand] modules` 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. + /// Borrow the validated bidder route codes in deterministic order. /// - /// This intentionally exposes route keys rather than provider identities or - /// profile configuration for the browser Prebid injection boundary. - pub(crate) fn browser_bidder_codes(&self) -> impl Iterator { + /// These are the route keys alone, with no source name and no + /// implementation setting, so a page module may show them to a browser. + pub 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 +614,123 @@ impl AuctionPlan { self.signing_enabled } - /// Borrow the separately validated static mediator identifier. + /// Borrow the selected ad server, when `[ad-server] module` 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) +/// The registered implementation `written` names in `[section]`. +/// +/// An implementation is named by its module path, which a section named by +/// the implementation's type folder may shorten, so `mock` in `[ad-server]` +/// is `ad-server.mock`. The refusal lists what the build has, as the section +/// would write it. +fn resolve_implementation( + section: &str, + name: &str, + written: &str, + registered: &[&'static T], + id: fn(&T) -> &'static str, +) -> Result<&'static T, Report> { + let ids = registered + .iter() + .map(|implementation| id(implementation)) + .collect::>(); + crate::module_name::resolve(section, written, &ids) + .and_then(|resolved| { + registered + .iter() + .copied() + .find(|implementation| id(implementation) == resolved) + }) + .ok_or_else(|| { + let mut known = ids + .iter() + .map(|id| crate::module_name::short_form(section, id)) + .collect::>(); + known.sort_unstable(); + configuration_error(format!( + "[{section}] `{name}` uses implementation `{written}`, 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" + "[{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 == "aps" - && endpoint - .path() - .trim_end_matches('/') - .ends_with("/e/dtb/bid") - { - return Err(configuration_error(format!( - "provider `{provider_id}` uses unsupported legacy APS endpoint `/e/dtb/bid`" - ))); - } - if profile_id == "prebid-server" { - normalize_prebid_server_endpoint(&mut endpoint); - } - Ok(CanonicalProviderEndpoint(endpoint)) + 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 +767,63 @@ 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::auction::test_support::plain_fixture::PlainDemand; + 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 { + Map::from_iter([ + (IMPLEMENTATION_KEY.to_string(), json!(implementation)), + ( + ENDPOINT_KEY.to_string(), + json!("https://bid.example/openrtb2/auction"), + ), + ]) } - fn config(providers: BTreeMap) -> AuctionPlanConfig { + /// 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(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("auction.plain-fixture"))]) + } + fn id(value: &str) -> ProviderId { ProviderId::from_str(value).expect("should parse provider ID") } @@ -670,32 +832,13 @@ mod tests { BidderId::from_str(value).expect("should parse bidder ID") } - fn nested_object(levels: usize) -> Value { - let mut value = Value::String("leaf".to_string()); - for level in 0..levels { - value = Value::Object(serde_json::Map::from_iter([( - format!("level-{level}"), - value, - )])); - } - value - } - - fn nested_array(levels: usize) -> Value { - let mut value = Value::String("leaf".to_string()); - for _ in 0..levels { - value = Value::Array(vec![value]); - } - value - } - #[test] fn target_validation_accepts_fanout_and_rejects_unsupported_targets() { - let providers = BTreeMap::from([ - (id("provider-one"), provider("standard")), - (id("provider-two"), provider("standard")), - ]); - let plan = AuctionPlan::compile(config(providers)).expect("should compile plan"); + let plan = AuctionPlan::compile(config(vec![ + ("provider_one", table("auction.plain-fixture")), + ("provider_two", table("auction.plain-fixture")), + ])) + .expect("should compile plan"); assert!( plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) @@ -708,253 +851,171 @@ 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" + 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("auction.plain-fixture")), + ("left_behind".to_string(), table("auction.plain-fixture")), + ]), ); - } - - #[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"); + let error = AuctionPlan::compile(raw).expect_err("should refuse a table nothing selects"); + let message = error.to_string(); assert!( - error - .to_string() - .contains("exceeding its auction budget of 160") + message.contains("left_behind") && message.contains("modules"), + "should name the table and the selector: {error:?}" ); } #[test] - fn fastly_backend_quota_uses_auction_timeout_as_reachable_bucket_ceiling() { - let providers = (0..21) - .map(|index| { - let mut provider = provider("standard"); - provider.timeout_ms = Some(1000); - (id(&format!("provider-{index}")), provider) - }) - .collect(); - let mut bounded = config(providers); - bounded.timeout_ms = 100; - let plan = AuctionPlan::compile(bounded).expect("should compile bounded provider plan"); - - assert!( - plan.providers() - .iter() - .all(|provider| provider.timeout_ms == 1000), - "quota validation must not rewrite configured provider timeouts" - ); - plan.validate_for_target(crate::platform::AuctionTargetId::Fastly) - .expect("21 providers reach only the 50ms and 100ms Fastly buckets"); - } - - #[test] - fn disabled_target_validation_skips_fanout_and_collision_checks() { - let providers = BTreeMap::from([ - (id("provider-one"), provider("standard")), - (id("provider-two"), provider("standard")), - ]); - let disabled = AuctionPlan::compile(config(providers)) - .expect("should compile plan") - .with_enabled(false); - - for target in [ - crate::platform::AuctionTargetId::Cloudflare, - crate::platform::AuctionTargetId::Spin, + fn an_unknown_implementation_names_the_ones_this_build_has() { + let mut absent = table("auction.plain-fixture"); + 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", + "auction.plain-fixture", + "auction.fixture", ] { - disabled - .validate_for_target(target) - .expect("disabled dormant providers should skip target validation"); + 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_demand_source_names_its_implementation_in_full() { + // `[demand]` is no implementation's type folder, so a source's name + // never stands for its implementation. + let unnamed = Map::from_iter([( + ENDPOINT_KEY.to_string(), + json!("https://bid.example/openrtb2/auction"), + )]); + let error = AuctionPlan::compile(config(vec![("exchange", unnamed)])) + .expect_err("should refuse a source with no implementation line"); + let message = error.to_string(); + assert!( + message.contains("uses implementation `exchange`") + && message.contains("auction.plain-fixture"), + "should list the implementations by module path: {error:?}" + ); } #[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, - }; + fn an_ad_server_named_by_its_short_form_needs_no_implementation_line() { + let mut raw = one_source(); + raw.adserver = ProviderChoice::new( + Some("fixture".to_string()), + BTreeMap::from([( + "fixture".to_string(), + Map::from_iter([( + ENDPOINT_KEY.to_string(), + json!("https://adserver.example/mediate"), + )]), + )]), + ); + let plan = AuctionPlan::compile(raw).expect("should resolve `fixture` within [ad-server]"); + assert_eq!( + plan.adserver().map(|adserver| adserver.implementation.id), + Some("ad-server.fixture") + ); + } - let error = collision_plan - .validate_for_target(crate::platform::AuctionTargetId::Axum) - .expect_err("should reject predicted backend collision"); - assert!(error.to_string().contains("same backend name")); + #[test] + fn a_demand_table_needs_an_endpoint() { + let mut without = table("auction.plain-fixture"); + 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 explicit_only = table("auction.fixture"); + explicit_only.insert(ROUTING_KEY.to_string(), json!("all_eligible")); + let error = AuctionPlan::compile(config(vec![("explicit_main", explicit_only)])) + .expect_err("should refuse all_eligible routing for the implementation"); let message = error.to_string(); - for expected in ["pbs-main", "all_eligible", "prebid-server"] { + for expected in ["explicit_main", "all_eligible", "auction.fixture"] { 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 plain = table("auction.plain-fixture"); + plain.insert(ROUTING_KEY.to_string(), json!("all_eligible")); + AuctionPlan::compile(config(vec![("plain_main", plain)])) + .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 { - provider: id("one"), - }, + BidderRouteConfig { module: id("one") }, ); assert!( AuctionPlan::compile(raw).is_err(), "exact reserved bidder ID should be rejected" ); - let mut case_distinct = config(BTreeMap::from([(id("one"), provider("standard"))])); + let mut case_distinct = one_source(); case_distinct.bidders.insert( bidder("TrustedServer"), - BidderRouteConfig { - provider: id("one"), - }, + BidderRouteConfig { module: id("one") }, ); assert!( AuctionPlan::compile(case_distinct).is_ok(), @@ -963,26 +1024,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("auction.plain-fixture")), + ("a_provider", table("auction.plain-fixture")), + ]); raw.bidders.insert( bidder("z-bidder"), BidderRouteConfig { - provider: id("z-provider"), + module: id("z_provider"), }, ); raw.bidders.insert( bidder("a-bidder"), BidderRouteConfig { - provider: id("a-provider"), + module: 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 +1056,95 @@ 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![ + ("source_a", table("auction.plain-fixture")), + ("source_b", table("auction.plain-fixture")), + ])) + .expect("should compile two sources of one implementation"); 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 == "auction.plain-fixture"), + "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("auction.fixture"); + override_table.insert(TIMEOUT_KEY.to_string(), json!(321)); + let plan = AuctionPlan::compile(config(vec![ + ("plain_one", table("auction.plain-fixture")), + ("fixed_one", table("auction.fixture")), + ("fixed_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["plain_one"], 1500); + assert_eq!(timeouts["fixed_one"], 1000); + assert_eq!(timeouts["fixed_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![("source", table("auction.fixture"))])) .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("auction.plain-fixture")); + assert!(plan.has_implementation("auction.fixture")); + + let plan = AuctionPlan::compile(one_source()).expect("should compile a plain source"); + assert!( + plan.has_implementation("auction.plain-fixture"), + "a validated plan should say which implementation it selected" ); - let plan = AuctionPlan::compile(config(providers)).expect("should compile APS plan"); + assert!(!plan.has_implementation("auction.fixture")); assert!( - plan.has_profile("aps"), - "validated plan should expose APS renderer capability" + plan.providers()[0].demand.as_any().is::(), + "the 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"), + module: 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("auction.plain-fixture"); + 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,52 +1157,68 @@ mod tests { "https://bid.example/path#fragment", "/relative", ] { - let mut raw_provider = provider("standard"); - raw_provider.endpoint = endpoint.to_string(); + let mut raw = table("auction.plain-fixture"); + 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()); } #[test] - fn compiler_normalizes_only_prebid_server_origin_and_canonical_paths() { + 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("auction.plain-fixture"); + 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("auction.plain-fixture"); + 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 an_implementation_completes_an_endpoint_and_the_plain_one_leaves_it_as_written() { for (configured, expected) in [ ( - "https://pbs.example", - "https://pbs.example/openrtb2/auction", + "https://exchange.example", + "https://exchange.example/fixture/auction", ), ( - "https://pbs.example/", - "https://pbs.example/openrtb2/auction", + "https://exchange.example/", + "https://exchange.example/fixture/auction", ), ( - "https://pbs.example/openrtb2/auction", - "https://pbs.example/openrtb2/auction", + "https://exchange.example?region=example", + "https://exchange.example/fixture/auction?region=example", ), ( - "https://pbs.example/openrtb2/auction/", - "https://pbs.example/openrtb2/auction", - ), - ( - "https://pbs.example?region=example", - "https://pbs.example/openrtb2/auction?region=example", - ), - ("https://pbs.example/bid", "https://pbs.example/bid"), - ( - "https://pbs.example/custom/pbs", - "https://pbs.example/custom/pbs", + "https://exchange.example/bid", + "https://exchange.example/bid", ), ] { - let mut pbs = provider("prebid-server"); - pbs.endpoint = configured.to_string(); - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("pbs"), pbs)]))) - .expect("should compile Prebid Server endpoint"); + let mut source = table("auction.fixture"); + source.insert(ENDPOINT_KEY.to_string(), json!(configured)); + let plan = AuctionPlan::compile(config(vec![("source", source)])) + .expect("should compile the endpoint"); assert_eq!( plan.providers()[0].endpoint.as_str(), expected, @@ -1163,124 +1226,26 @@ 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 plain = table("auction.plain-fixture"); + plain.insert(ENDPOINT_KEY.to_string(), json!("https://bid.example/")); + let plan = AuctionPlan::compile(config(vec![("plain", plain)])) + .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)]))) - .expect("should compile APS endpoint"); - assert_eq!( - plan.providers()[0].endpoint.as_str(), - "https://aps.example/e/pb/bid" - ); - } - - #[test] - fn standard_extensions_are_typed_bounded_and_cannot_claim_reserved_fields() { - let mut valid = provider("standard"); - valid.profile_config = serde_json::json!({ - "request_ext": {"fictional_account": "example"}, - "imp_ext": {"placement_group": "display"} - }); - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), valid)]))) - .expect("should compile static extensions"); - let CompiledOpenRtbProfile::Standard(standard) = &plan.providers()[0].profile else { - panic!("should compile standard profile") - }; - assert_eq!( - standard.request_ext.as_object()["fictional_account"], - "example" - ); - - let mut standard_owned_fields = provider("standard"); - standard_owned_fields.profile_config = serde_json::json!({ - "request_ext": { - "account": "example-account", - "sdk": {"source": "example"}, - "prebid": {"example": true} - }, - "imp_ext": {"prebid": {"example": true}} - }); - AuctionPlan::compile(config(BTreeMap::from([( - id("standard-owned-fields"), - standard_owned_fields, - )]))) - .expect("should allow standard static extensions outside common-owned fields"); - - for profile_config in [ - serde_json::json!({"request_ext": "bad"}), - serde_json::json!({"request_ext": {"trusted_server": {}}}), - ] { - let mut invalid = provider("standard"); - invalid.profile_config = profile_config; - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); - } - let oversized = "x".repeat(16 * 1024); - let mut invalid = provider("standard"); - invalid.profile_config = serde_json::json!({"request_ext": {"value": oversized}}); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); - - let too_many_keys = (0..257) - .map(|index| (format!("key-{index}"), Value::Bool(true))) - .collect::>(); - let mut invalid = provider("standard"); - invalid.profile_config = serde_json::json!({"imp_ext": too_many_keys}); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("one"), invalid)]))).is_err()); - } - - #[test] - fn standard_extension_depth_counts_container_levels() { - let mut object_valid = provider("standard"); - object_valid.profile_config = serde_json::json!({"request_ext": nested_object(8)}); - AuctionPlan::compile(config(BTreeMap::from([(id("object-valid"), object_valid)]))) - .expect("should accept eight nested object levels"); - - let mut object_invalid = provider("standard"); - object_invalid.profile_config = serde_json::json!({"request_ext": nested_object(9)}); - assert!( - AuctionPlan::compile(config(BTreeMap::from([( - id("object-invalid"), - object_invalid, - )]))) - .is_err(), - "should reject nine nested object levels" - ); - - let mut array_valid = provider("standard"); - array_valid.profile_config = serde_json::json!({"request_ext": {"value": nested_array(7)}}); - AuctionPlan::compile(config(BTreeMap::from([(id("array-valid"), array_valid)]))) - .expect("should accept one object plus seven nested array levels"); - - let mut array_invalid = provider("standard"); - array_invalid.profile_config = - serde_json::json!({"request_ext": {"value": nested_array(8)}}); - assert!( - AuctionPlan::compile(config(BTreeMap::from([( - id("array-invalid"), - array_invalid, - )]))) - .is_err(), - "should reject one object plus eight nested array levels" - ); } #[test] fn notification_policy_rejects_duplicates_and_bounds() { - let mut valid = provider("standard"); - valid.notifications = NotificationConfig { - suppress_all: true, - suppress_seats: vec!["seat-b".to_string(), "seat-a".to_string()], - }; - let plan = AuctionPlan::compile(config(BTreeMap::from([(id("one"), valid)]))) + let mut valid = table("auction.plain-fixture"); + 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 +1256,43 @@ 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("auction.plain-fixture"); + 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()); - - let mut non_object = provider("standard"); - non_object.profile_config = Value::Null; + fn an_implementation_rejects_settings_it_does_not_know() { + let mut source = table("auction.fixture"); + source.insert("browser_only".to_string(), json!(true)); + let error = AuctionPlan::compile(config(vec![("source", source)])) + .expect_err("should refuse a setting the implementation does not know"); assert!( - AuctionPlan::compile(config(BTreeMap::from([(id("standard"), non_object,)]))).is_err() + format!("{error:?}").contains("browser_only"), + "should name the setting: {error:?}" ); - - let mut aps = provider("aps"); - aps.endpoint = "https://aps.example/e/pb/bid".to_string(); - aps.profile_config = serde_json::json!({ - "account_id": "example-account", - "inventory_domain": "publisher.example" - }); - assert!(AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))).is_err()); } #[test] fn enabled_signing_requires_nonblank_existing_global_store_ids() { - let mut raw = config(BTreeMap::from([(id("one"), provider("standard"))])); + let mut raw = one_source(); raw.request_signing = Some(RequestSigning { enabled: true, config_store_id: " ".to_string(), @@ -1337,7 +1303,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 +1316,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 plain = table("auction.plain-fixture"); + plain.insert(ROUTING_KEY.to_string(), json!("all_eligible")); + let mut raw = config(vec![("one", plain)]); 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("fixture".to_string()), + BTreeMap::from([( + "fixture".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("fixture") + ); + + 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:?}" + ); + } - let mut invalid = config(BTreeMap::new()); - invalid.mediator = Some("generic-mediator".to_string()); - assert!(AuctionPlan::compile(invalid).is_err()); + #[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!("fixture")), + ( + 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, "ad-server.fixture"); + + let mut unknown_setting = one_source(); + unknown_setting.adserver = ProviderChoice::new( + Some("fixture".to_string()), + BTreeMap::from([( + "fixture".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 51a91e69c6..0000000000 --- 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 62b2ddc8fc..d727629862 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 { @@ -233,25 +215,13 @@ impl GenericOpenRtbProvider { self.plan.timeout_ms } - #[cfg(test)] + #[cfg(any(test, feature = "test-utils"))] 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. @@ -341,26 +311,39 @@ impl GenericOpenRtbProvider { })); } + let sent_impression_ids = request + .imp + .iter() + .filter_map(|impression| impression.id.as_deref()) + .collect::>(); let body = serde_json::to_vec(&request).change_context(TrustedServerError::Auction { message: format!( "Provider {} request serialization failed", self.provider_name() ), })?; - let 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 +351,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,42 +366,23 @@ 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(_) => { - debug_assert!( - request.imp.iter().all(|impression| impression.id.is_some()), - "should populate every PBS impression ID" - ); - let sent_impression_ids = request - .imp - .iter() - .filter_map(|impression| impression.id.as_deref()) - .collect::>(); - GenericOpenRtbParseState::Prebid { - provider_id: self.provider_name().to_string(), - auction_id: input.common_request().id.clone(), - input: input.filtered_slots(|slot| { - sent_impression_ids.contains(slot.slot().id.as_str()) - }), - } - } - 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 + .filtered_slots(|slot| sent_impression_ids.contains(slot.slot().id.as_str())), + 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, @@ -446,153 +399,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 2b8135da3c..a8df9c1a29 100644 --- a/crates/trusted-server-core/src/auction/routing.rs +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -17,8 +17,8 @@ const STORED_REQUEST_FIELD: &str = "storedRequest"; /// Maximum bidder entries admitted from one browser `bidderParams` envelope. pub(crate) const MAX_BIDDER_ENTRIES: usize = 128; -/// Maximum UTF-8 byte length of the optional Prebid zone fact. -pub(crate) const MAX_PREBID_ZONE_BYTES: usize = 256; +/// Maximum UTF-8 byte length of the zone a browser may name for a slot. +pub(crate) const MAX_ZONE_BYTES: usize = 256; /// Immutable provider-local routing output in deterministic provider-ID order. #[derive(Debug, Clone)] @@ -26,7 +26,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,8 +47,8 @@ impl RoutedAuction { self.diagnostics } - /// Request headers approved for later Prebid transport forwarding. - pub(crate) fn prebid_transport_headers(&self) -> &PrebidTransportHeaders { + /// Request headers approved for forwarding to a demand source. + pub(crate) fn transport_headers(&self) -> &TransportHeaders { &self.transport_headers } @@ -122,7 +122,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), @@ -137,7 +137,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 } @@ -147,11 +149,14 @@ 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 } @@ -165,25 +170,30 @@ impl ProviderAuctionInput { /// 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, stored_request: StoredRequestIntent, } 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() } #[cfg(test)] @@ -191,38 +201,49 @@ impl ProviderSlotInput { self.bidder_params.is_empty() && self.allows_stored_fallback() } - pub(crate) fn allows_stored_fallback(&self) -> bool { + /// Whether a stored request may stand in for this slot when it is left + /// with no usable inline bidder parameters. + #[must_use] + pub fn allows_stored_fallback(&self) -> bool { self.stored_request .allows_fallback(!self.bidder_params.is_empty()) } } -/// Request headers approved for later Prebid transport forwarding. +/// Request headers approved for forwarding to a demand source. /// /// 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() } @@ -286,14 +307,14 @@ impl StoredRequestIntent { struct NormalizedSlotDemand { bidder_params: BTreeMap, stored_request: StoredRequestIntent, - 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, } @@ -330,7 +351,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") @@ -344,7 +365,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(), }) @@ -385,7 +406,7 @@ pub(crate) fn route_auction_with_trusted_routes( let trusted_route = trusted_provider_indices.contains(&provider_index); let include = !bidder_params.is_empty() || trusted_route - || if builder.is_prebid { + || if builder.serves_stored_requests { demand.stored_request.allows_fallback(false) } else { builder.routing == RoutingMode::AllEligible @@ -396,9 +417,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(), stored_request: demand.stored_request, }); @@ -513,9 +534,9 @@ 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(Value::String(zone)) if zone.len() <= MAX_ZONE_BYTES => Some(zone.clone()), Some(_) => return None, }; let params = match object.get(BIDDER_PARAMS_FIELD) { @@ -533,7 +554,7 @@ fn normalize_envelope(envelope: &Value) -> Option { let Some(params) = params else { return Some(NormalizedSlotDemand { stored_request, - prebid_zone, + zone, ..Default::default() }); }; @@ -552,7 +573,7 @@ fn normalize_envelope(envelope: &Value) -> Option { Some(NormalizedSlotDemand { bidder_params, stored_request, - prebid_zone, + zone, }) } @@ -565,97 +586,68 @@ 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(open_routing: RoutingMode) -> crate::auction::plan::AuctionPlanConfig { + let mut config = plan_config(vec![ + ( + "open_primary", + provider("auction.plain-fixture", open_routing), + ), + ( + "stored_a", + provider("auction.fixture", RoutingMode::Explicit), + ), + ( + "stored_b", + provider("auction.fixture", RoutingMode::Explicit), + ), + ( + "openrtb_direct", + provider("auction.plain-fixture", 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 { + module: ProviderId::from_str("stored_a").expect("should parse provider"), + }, + ), + ( + BidderId::from_str("beta").expect("should parse bidder"), + BidderRouteConfig { + module: 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 { @@ -722,8 +714,8 @@ mod tests { } #[test] - fn pbs_admission_respects_intent_without_changing_aps_eligibility() { - for (params, pbs_ids) in [ + fn stored_admission_respects_intent_without_changing_all_eligible_routing() { + for (params, stored_ids) in [ (json!({"bidderParams":{}, "storedRequest":false}), vec![]), ( json!({"bidderParams":{"unknown":{"id":1}}, "storedRequest":false}), @@ -732,18 +724,18 @@ mod tests { (json!({"bidderParams":{"unknown":{"id":1}}}), vec![]), ( json!({"bidderParams":{"alpha":{}}, "storedRequest":false}), - vec!["pbs-a"], + vec!["stored_a"], ), - (json!({"bidderParams":{"alpha":{}}}), vec!["pbs-a"]), + (json!({"bidderParams":{"alpha":{}}}), vec!["stored_a"]), ( json!({"bidderParams":{"alpha":{"id":1}}, "storedRequest":true}), - vec!["pbs-a", "pbs-b"], + vec!["stored_a", "stored_b"], ), ( json!({"bidderParams":{}, "storedRequest":true}), - vec!["pbs-a", "pbs-b"], + vec!["stored_a", "stored_b"], ), - (json!({"bidderParams":{}}), vec!["pbs-a", "pbs-b"]), + (json!({"bidderParams":{}}), vec!["stored_a", "stored_b"]), ] { let routed = route_auction( request(vec![slot(HashMap::from([( @@ -754,8 +746,8 @@ mod tests { &plan(), None, ); - let mut expected = vec!["aps-primary"]; - expected.extend(pbs_ids); + let mut expected = vec!["open_primary"]; + expected.extend(stored_ids); assert_eq!( routed .inputs() @@ -790,11 +782,11 @@ mod tests { route_auction(request(vec![slot(bidders)]), &inbound(), &plan(), None); assert_eq!(routed.diagnostics().malformed_envelope_count(), 1); assert_eq!(routed.inputs().len(), if direct { 2 } else { 1 }); - assert_eq!(routed.inputs()[0].provider_id().as_str(), "aps-primary"); + assert_eq!(routed.inputs()[0].provider_id().as_str(), "open_primary"); if direct { - let slot = &input(&routed, "pbs-a").slots()[0]; + let slot = &input(&routed, "stored_a").slots()[0]; assert!(!slot.allows_stored_fallback()); - assert_eq!(slot.prebid_zone(), None); + assert_eq!(slot.zone(), None); assert_eq!( slot.bidder_params().values().collect::>(), vec![&json!({"direct":1})] @@ -810,11 +802,11 @@ mod tests { for (params, expected) in [ ( json!({"bidderParams": {}, "storedRequest": true}), - vec!["aps-primary", "pbs-a", "pbs-b"], + vec!["open_primary", "stored_a", "stored_b"], ), ( json!({"bidderParams": {"alpha": {"placement": 1}}, "storedRequest": false}), - vec!["aps-primary", "pbs-a"], + vec!["open_primary", "stored_a"], ), ] { let routed = route_auction( @@ -862,16 +854,16 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["aps-primary", "pbs-a", "pbs-b"], - "{name} should fan out to both PBS providers while APS remains all-eligible" + vec!["open_primary", "stored_a", "stored_b"], + "{name} should fan out to both stored-request sources while the all-eligible source keeps every slot" ); assert!( - input(&routed, "pbs-a").slots()[0].allows_stored_fallback_without_candidates(), + input(&routed, "stored_a").slots()[0].allows_stored_fallback_without_candidates(), "{name} should create stored intent" ); assert!( - input(&routed, "pbs-b").slots()[0].allows_stored_fallback_without_candidates(), - "{name} should create stored intent for every PBS provider" + input(&routed, "stored_b").slots()[0].allows_stored_fallback_without_candidates(), + "{name} should create stored intent for every stored-request source" ); } } @@ -885,12 +877,12 @@ mod tests { None, ); assert!( - input(&routed, "pbs-a").slots()[0].allows_stored_fallback_without_candidates(), + input(&routed, "stored_a").slots()[0].allows_stored_fallback_without_candidates(), "empty canonical demand should preserve stored-request behavior" ); assert!( - input(&routed, "pbs-b").slots()[0].allows_stored_fallback_without_candidates(), - "empty canonical demand should fan out to same-profile PBS plans" + input(&routed, "stored_b").slots()[0].allows_stored_fallback_without_candidates(), + "empty canonical demand should fan out to the sources of one implementation" ); } @@ -921,7 +913,7 @@ mod tests { ("too many bidders", envelope(Some(too_many))), ( "oversized zone", - json!({"bidderParams": {}, "zone": "z".repeat(MAX_PREBID_ZONE_BYTES + 1)}), + json!({"bidderParams": {}, "zone": "z".repeat(MAX_ZONE_BYTES + 1)}), ), ("nonstring zone", json!({"bidderParams": {}, "zone": 1})), ]; @@ -938,13 +930,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() != "stored_a" + && provider.provider_id().as_str() != "stored_b" }), - "{name} should not produce stored or inline PBS demand" + "{name} should not produce stored or inline demand" ); assert_eq!( - input(&routed, "standard-direct").slots()[0] + input(&routed, "openrtb_direct").slots()[0] .bidder_params() .len(), 1, @@ -954,7 +946,7 @@ mod tests { routed .inputs() .iter() - .any(|provider| provider.provider_id().as_str() == "aps-primary"), + .any(|provider| provider.provider_id().as_str() == "open_primary"), "{name} should preserve independent all-eligible participation" ); } @@ -1025,12 +1017,12 @@ mod tests { assert_eq!( routed.inputs().len(), 1, - "only all-eligible APS should remain" + "only the all-eligible source should remain" ); assert_eq!( routed.inputs()[0].provider_id().as_str(), - "aps-primary", - "unknown demand should not cause PBS fallback" + "open_primary", + "unknown demand should not cause a stored fallback" ); } @@ -1055,7 +1047,7 @@ mod tests { None, ); assert_eq!( - input(&routed, "pbs-a").slots()[0].bidder_params() + input(&routed, "stored_a").slots()[0].bidder_params() [&BidderId::from_str("alpha").expect("should parse bidder")]["source"], expected_source, "{name} should follow deterministic collision semantics" @@ -1110,7 +1102,7 @@ mod tests { TRUSTED_SERVER_ENVELOPE.to_string(), json!({"zone": "home", "bidderParams": null}), ), - ("alpha".to_string(), json!({"placement": "pbs"})), + ("alpha".to_string(), json!({"placement": "stored"})), ("beta".to_string(), json!({"placement": "direct"})), ]))]), &inbound(), @@ -1124,31 +1116,35 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["aps-primary", "pbs-a", "pbs-b", "standard-direct"], + vec!["open_primary", "stored_a", "stored_b", "openrtb_direct"], "inputs should follow deterministic provider-ID order" ); - let aps = input(&routed, "aps-primary") + let open = input(&routed, "open_primary") .slots() .first() .expect("should have slot"); assert!( - aps.bidder_params().is_empty(), - "APS must receive no foreign params" + open.bidder_params().is_empty(), + "a source that takes no bidder params must receive none" + ); + assert_eq!( + open.zone(), + None, + "a source that takes no bidder params must receive no zone" ); - assert_eq!(aps.prebid_zone(), None, "APS must receive no Prebid zone"); - let pbs_a = &input(&routed, "pbs-a").slots()[0]; - assert_eq!(pbs_a.bidder_params().len(), 1); + let stored_a = &input(&routed, "stored_a").slots()[0]; + assert_eq!(stored_a.bidder_params().len(), 1); assert!( - !pbs_a.allows_stored_fallback_without_candidates(), - "inline params should win for this PBS provider" + !stored_a.allows_stored_fallback_without_candidates(), + "inline params should win for this source" ); - assert_eq!(pbs_a.prebid_zone(), Some("home")); - let pbs_b = &input(&routed, "pbs-b").slots()[0]; - assert!(pbs_b.bidder_params().is_empty()); - assert!(pbs_b.allows_stored_fallback_without_candidates()); - let direct = &input(&routed, "standard-direct").slots()[0]; + assert_eq!(stored_a.zone(), Some("home")); + let stored_b = &input(&routed, "stored_b").slots()[0]; + assert!(stored_b.bidder_params().is_empty()); + assert!(stored_b.allows_stored_fallback_without_candidates()); + let direct = &input(&routed, "openrtb_direct").slots()[0]; 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()); @@ -1198,7 +1194,7 @@ mod tests { assert_eq!( routed.inputs().len(), 3, - "APS and two PBS providers should receive the eligible slot" + "the all-eligible source and two stored-request sources should receive the eligible slot" ); for provider in routed.inputs() { assert_eq!(provider.slots().len(), 1); @@ -1225,7 +1221,7 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["aps-primary", "pbs-a", "pbs-b", "standard-direct"], + vec!["open_primary", "stored_a", "stored_b", "openrtb_direct"], "no-banner auction should retain every provider's deterministic skip outcome" ); } @@ -1247,17 +1243,17 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["pbs-a", "pbs-b", "standard-direct"], + vec!["stored_a", "stored_b", "openrtb_direct"], "explicit providers with no routed demand should be retained as skipped" ); } #[test] - fn trusted_routes_admit_explicit_aps_and_standard_and_ignore_unknown_provider() { + fn trusted_routes_admit_explicit_sources_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("open_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([( @@ -1275,7 +1271,7 @@ mod tests { .iter() .map(|input| input.provider_id().as_str()) .collect::>(), - vec!["aps-primary", "standard-direct"], + vec!["open_primary", "openrtb_direct"], "only known server-owned provider routes should admit explicit providers" ); assert_eq!( @@ -1289,7 +1285,7 @@ mod tests { .iter() .map(ProviderId::as_str) .collect::>(), - vec!["pbs-a", "pbs-b"], + vec!["stored_a", "stored_b"], "unrouted explicit providers should retain skip outcomes" ); } @@ -1335,7 +1331,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(), @@ -1355,8 +1351,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, + "open_primary" => 900, + id if id.starts_with("stored_") => 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 e0a819ba38..0beb1508e4 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, @@ -338,7 +338,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, @@ -576,13 +576,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( @@ -775,14 +775,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)) @@ -791,7 +791,7 @@ fn push_bid_rows( observation, event_ts, request, - &mediator_response.provider, + &adserver_response.provider, winning, 1, winning.price, @@ -1208,7 +1208,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(), @@ -1274,7 +1274,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(), @@ -1310,7 +1310,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(), @@ -1334,15 +1334,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(), @@ -1382,7 +1382,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(), @@ -1423,7 +1423,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(), @@ -1452,17 +1452,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(), }; @@ -1487,12 +1487,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" ); } @@ -1501,7 +1501,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 a83f8899a8..411e1d268f 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -1,3 +1,6 @@ +//! Helpers for tests of the auction, in core and in an implementation's own +//! crate. + use std::collections::HashMap; use std::sync::LazyLock; @@ -17,7 +20,8 @@ use crate::settings::Settings; static TEST_SERVICES: LazyLock = LazyLock::new(noop_services); -pub(crate) fn create_test_auction_context<'a>( +/// An auction context over services that do nothing. +pub fn create_test_auction_context<'a>( settings: &'a Settings, request: &'a Request, timeout_ms: u32, @@ -33,13 +37,13 @@ pub(crate) fn create_test_auction_context<'a>( } } -/// Build canonical request facts shared by the PBS and APS Stage 1 wire goldens. +/// Build canonical request facts shared by the implementations' wire goldens. /// -/// The supported and unsupported formats deliberately exercise each profile's -/// existing filtering and field-ownership policy. `trustedServer` bidder -/// parameters are included to pin that PBS consumes them while APS ignores -/// them. -pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { +/// The supported and unsupported formats deliberately exercise each +/// implementation's filtering and field-ownership policy. `trustedServer` +/// bidder parameters are included to pin which implementation consumes them +/// and which ignores them. +pub fn canonical_parity_auction_request() -> AuctionRequest { AuctionRequest { id: "fictional-auction".to_string(), slots: vec![AdSlot { @@ -119,3 +123,642 @@ pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { context: HashMap::new(), } } + +/// The signing input the request goldens were captured with. +pub fn golden_signing_params() -> crate::request_signing::SigningParams { + crate::request_signing::SigningParams { + request_id: "fictional-auction".to_string(), + request_host: "publisher.example".to_string(), + request_scheme: "https".to_string(), + timestamp: 1_706_900_000, + } +} + +/// The one-source plan the request goldens were captured with: a source +/// called `fictional_provider`, running `implementation` with `settings`. +/// +/// The source takes every eligible slot when `all_eligible` is set. Otherwise +/// the bidder `exampleBidder` is routed to it, which is how a source that +/// takes only routed bidders is reached. +pub fn golden_plan_config( + implementation: &str, + settings: serde_json::Value, + all_eligible: bool, + extra: &[crate::integrations::IntegrationBuilder], +) -> crate::auction::plan::AuctionPlanConfig { + let mut table = demand_table(implementation, "https://exchange.example.test/openrtb"); + table.insert("timeout_ms".to_string(), json!(321)); + if all_eligible { + table.insert("routing".to_string(), json!("all_eligible")); + } + if let serde_json::Value::Object(settings) = settings { + table.extend(settings); + } + let mut config = plan_config_with(vec![("fictional_provider", table)], extra); + config.timeout_ms = 321; + if !all_eligible { + config.bidders.insert( + "exampleBidder".parse().expect("should parse bidder ID"), + crate::auction::plan::BidderRouteConfig { + module: "fictional_provider" + .parse() + .expect("should parse provider ID"), + }, + ); + } + config +} + +/// The inbound request the request goldens were captured with. +pub fn golden_inbound_request() -> Request { + Request::builder() + .uri("https://publisher.example/auction") + .header( + http::header::REFERER, + "https://referrer.example/story?fictional=1", + ) + .header(http::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .header("dnt", "1") + .body(EdgeBody::empty()) + .expect("should build inbound request") +} + +/// A signer over one fixed key, so a signed golden is the same on every run. +pub fn deterministic_signer() -> crate::request_signing::RequestSigner { + use base64::Engine as _; + + use crate::platform::test_support::{ + HashMapConfigStore, HashMapSecretStore, NoopHttpClient, + build_services_with_config_secret_and_http_client, + }; + + let config_data = HashMap::from([("current-kid".to_string(), "fictional-kid".to_string())]); + let secret_data = HashMap::from([( + "fictional-kid".to_string(), + base64::engine::general_purpose::STANDARD + .encode([7_u8; 32]) + .into_bytes(), + )]); + let services = build_services_with_config_secret_and_http_client( + HashMapConfigStore::new(config_data), + HashMapSecretStore::new(secret_data), + std::sync::Arc::new(NoopHttpClient), + ); + crate::request_signing::RequestSigner::from_services(&services) + .expect("should load deterministic signer") +} + +/// Builds the request the driver would send to the first demand source of +/// `plan` once `request` is routed to it, or `None` when the source keeps no +/// impression. +/// +/// A test in an implementation's own crate checks what its field policy and +/// its extensions do to a request through this. The signature, when `signer` +/// is given, is made over [`golden_signing_params`]. +/// +/// # Errors +/// +/// Returns the error the request builder returns. +pub fn build_for_first_source( + plan: &crate::auction::AuctionPlan, + request: AuctionRequest, + inbound: &Request, + transport_timeout_ms: u32, + signer: Option<&crate::request_signing::RequestSigner>, +) -> Result< + Option, + error_stack::Report, +> { + use crate::auction::openrtb::{OpenRtbBuildOutcome, RequestFinalization, build_request}; + + let routed = crate::auction::routing::route_auction(request, inbound, plan, None); + let outcome = build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + transport_timeout_ms, + &RequestFinalization { + signer, + signing_params: golden_signing_params(), + }, + )?; + Ok(match outcome { + OpenRtbBuildOutcome::Ready(request) => Some(request), + OpenRtbBuildOutcome::NoImpressions => None, + }) +} + +/// Routes `request` through `plan` and returns the input its first demand +/// source receives, or `None` when no source takes a slot. +/// +/// A test in an implementation's own crate hands this input to its own +/// response reader. +#[must_use] +pub fn route_to_first_source( + plan: &crate::auction::AuctionPlan, + request: AuctionRequest, + inbound: &Request, +) -> Option { + crate::auction::routing::route_auction(request, inbound, plan, None) + .inputs() + .first() + .cloned() +} + +/// The transport headers a routed auction admits from `inbound`, with the +/// attested client address it carries to every source's outbound request. +/// +/// A test in an implementation's own crate checks what it forwards upstream +/// through this. +#[must_use] +pub fn routed_transport( + plan: &crate::auction::AuctionPlan, + request: AuctionRequest, + inbound: &Request, + attested_client_ip: Option, +) -> ( + crate::auction::demand::TransportHeaders, + Option, +) { + let routed = crate::auction::routing::route_auction(request, inbound, plan, attested_client_ip); + ( + routed.transport_headers().clone(), + routed.attested_client_ip(), + ) +} + +/// Reads `response` as the first demand source of `plan` would, once +/// `request` is routed to it. +/// +/// A test in an implementation's own crate checks how its implementation +/// answers through this, with no upstream. +/// +/// # Errors +/// +/// Returns the error the source's own reading returns. +pub async fn parse_as_first_source( + plan: &crate::auction::AuctionPlan, + request: AuctionRequest, + response: crate::platform::PlatformResponse, + response_time_ms: u64, +) -> Result< + crate::auction::types::AuctionResponse, + error_stack::Report, +> { + let inbound = Request::new(EdgeBody::empty()); + let routed = crate::auction::routing::route_auction(request, &inbound, plan, None); + let provider = + crate::auction::provider::GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let state = provider.parse_state_for_test(routed.inputs()[0].clone()); + provider + .parse_response_with_state(response, response_time_ms, Some(state.as_ref())) + .await +} + +/// One `[demand.]` table naming an implementation, for tests that need a +/// compiled plan. +pub fn demand_table( + implementation: &str, + endpoint: &str, +) -> serde_json::Map { + serde_json::Map::from_iter([ + ("implementation".to_string(), json!(implementation)), + ("endpoint".to_string(), json!(endpoint)), + ]) +} + +/// An `[demand]` table selecting every name given, in the order given. +pub 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 fn plan_config( + tables: Vec<(&str, serde_json::Map)>, +) -> crate::auction::plan::AuctionPlanConfig { + plan_config_with(tables, &[]) +} + +/// A plan configuration selecting the named demand tables, with every +/// implementation the built-in builders register followed by the ones +/// `extra` registers, which is how a crate's own tests reach its +/// implementation. +pub fn plan_config_with( + tables: Vec<(&str, serde_json::Map)>, + extra: &[crate::integrations::IntegrationBuilder], +) -> crate::auction::plan::AuctionPlanConfig { + let builders = crate::integrations::all_builders(extra).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 sources under the names given, each running +/// `implementation` at an endpoint of its own and taking every eligible slot. +pub fn demand_named(implementation: &str, names: &[&str]) -> crate::provider_table::ProviderList { + demand_selection( + names + .iter() + .map(|name| { + let mut table = demand_table( + implementation, + &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] modules` selects. +/// +/// Production compiles its sources from the plan instead, so this exists only +/// so the parity tests can drive the pre-plan orchestrator. +#[cfg(test)] +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 +} + +/// A stand-in ad server implementation for core's own tests of the ad server +/// seam, selected with `[ad-server] module = "fixture"`. +/// +/// It reads an `endpoint`, which has to be a URL, and an optional +/// `timeout_ms` from its table and refuses any other setting, as an +/// implementation of a vendor's does. Its provider answers every request +/// with no bid and calls nothing. +#[cfg(test)] +pub(crate) mod adserver_fixture { + use std::sync::Arc; + + use async_trait::async_trait; + use error_stack::Report; + use serde::Deserialize; + + use crate::auction::demand::AdServerImplementation; + use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; + use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse}; + use crate::error::TrustedServerError; + use crate::platform::PlatformResponse; + + /// The name the stand-in is selected by, which `[ad-server]` shortens to + /// `fixture`. + pub(crate) const MODULE: &str = "ad-server.fixture"; + + /// The stand-in ad server implementation. + pub(crate) static ADSERVER: AdServerImplementation = + AdServerImplementation { id: MODULE, build }; + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct FixtureSettings { + endpoint: String, + #[serde(default = "default_timeout_ms")] + timeout_ms: u32, + } + + fn default_timeout_ms() -> u32 { + 500 + } + + fn build( + name: &str, + settings: &serde_json::Map, + ) -> Result, Report> { + let settings: FixtureSettings = serde_json::from_value(serde_json::Value::Object( + settings.clone(), + )) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `{MODULE}` settings: {error}"), + }) + })?; + url::Url::parse(&settings.endpoint).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `{MODULE}` endpoint: {error}"), + }) + })?; + Ok(Arc::new(FixtureAdServer::new(name, settings.timeout_ms))) + } + + /// The stand-in's provider. + pub(crate) struct FixtureAdServer { + name: String, + timeout_ms: u32, + } + + impl FixtureAdServer { + /// A provider called `name`. + pub(crate) fn new(name: &str, timeout_ms: u32) -> Self { + Self { + name: name.to_owned(), + timeout_ms, + } + } + } + + #[async_trait(?Send)] + impl AuctionProvider for FixtureAdServer { + fn provider_name(&self) -> &str { + &self.name + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( + self.name.clone(), + 0, + ))) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::no_bid(self.name.clone(), response_time_ms)) + } + + fn timeout_ms(&self) -> u32 { + self.timeout_ms + } + } +} + +/// A stand-in demand implementation for core's own tests of what the driver +/// does for an implementation that departs from the plain one. It serves +/// stored requests, forbids all-eligible routing, takes bidder parameters, +/// keeps the unsigned request identity and completes an endpoint that names +/// a host alone. +/// +/// It writes each impression's usable bidder parameters under +/// `ext.fixture.bidder`, or the slot id under `ext.fixture.stored` where the +/// slot allows a stored request, and leaves out an impression that has +/// neither. +#[cfg(test)] +pub(crate) mod demand_fixture { + use core::any::Any; + use std::sync::Arc; + + use async_trait::async_trait; + use error_stack::Report; + use serde::Deserialize; + use serde_json::{Map, Value, json}; + use url::Url; + + use crate::auction::demand::{ + CompiledDemand, DemandFieldPolicy, DemandImplementation, DemandResponse, + DemandTimeoutDefault, ProviderAuctionInput, RegsPolicy, RequestExtensions, + }; + use crate::auction::types::AuctionResponse; + use crate::error::TrustedServerError; + use crate::platform::PlatformResponse; + + /// The name an `implementation` line gives the stand-in. + pub(crate) const MODULE: &str = "auction.fixture"; + + /// The path the stand-in completes a bare host with. + pub(crate) const AUCTION_PATH: &str = "/fixture/auction"; + + /// The stand-in demand implementation. + pub(crate) static DEMAND: DemandImplementation = DemandImplementation { + id: MODULE, + default_timeout: DemandTimeoutDefault::Fixed(1000), + allows_all_eligible: false, + serves_stored_requests: true, + canonicalize_endpoint, + compile, + }; + + /// One compiled stand-in source. + #[derive(Debug, Clone, Default)] + pub(crate) struct FixtureDemand; + + #[derive(Debug, Deserialize, Default)] + #[serde(deny_unknown_fields)] + struct FixtureSettings {} + + fn compile( + settings: &Map, + ) -> Result, Report> { + FixtureSettings::deserialize(Value::Object(settings.clone())).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `{MODULE}` settings: {error}"), + }) + })?; + Ok(Arc::new(FixtureDemand)) + } + + fn canonicalize_endpoint(endpoint: &mut Url) -> Result<(), String> { + if matches!(endpoint.path(), "" | "/") { + endpoint.set_path(AUCTION_PATH); + } + Ok(()) + } + + #[async_trait(?Send)] + impl CompiledDemand for FixtureDemand { + fn field_policy(&self) -> DemandFieldPolicy { + DemandFieldPolicy { + imp_tagid: true, + site_ref: true, + precise_geo: true, + additional_consent: true, + unsigned_request_identity: true, + consumes_bidder_params: true, + regs: RegsPolicy::Jurisdiction, + ..DemandFieldPolicy::default() + } + } + + fn augment_request( + &self, + extensions: &mut RequestExtensions<'_>, + _input: &ProviderAuctionInput, + ) -> Result<(), Report> { + for impression in &mut extensions.impressions { + let slot = impression.slot; + let bidder = slot + .bidder_params() + .iter() + .filter(|(_, params)| { + params.as_object().is_some_and(|params| !params.is_empty()) + }) + .map(|(bidder, params)| (bidder.as_str().to_string(), params.clone())) + .collect::>(); + let mut fixture = Map::new(); + if !bidder.is_empty() { + fixture.insert("bidder".to_string(), Value::Object(bidder)); + } else if slot.allows_stored_fallback() { + fixture.insert("stored".to_string(), json!(slot.slot().id)); + } + if fixture.is_empty() { + impression.omitted = true; + continue; + } + *impression.ext = Some(Map::from_iter([( + "fixture".to_string(), + Value::Object(fixture), + )])); + } + *extensions.request = Some(Map::from_iter([( + "fixture".to_string(), + Value::Object(Map::new()), + )])); + Ok(()) + } + + async fn parse_response( + &self, + context: DemandResponse<'_>, + response: PlatformResponse, + ) -> Result> { + crate::auction::openrtb::parse_openrtb_response(context, response).await + } + + fn as_any(&self) -> &dyn Any { + self + } + } +} + +/// A stand-in for the plainest demand implementation there can be, which +/// core's own tests of the plan compiler and the request driver name where +/// they need a source. +/// +/// It takes every eligible slot, inherits the auction's timeout, leaves its +/// endpoint as written and has its response read the ordinary way. Its table +/// may set `request_ext` and `imp_ext`, which it writes into the request and +/// into every impression as given, so the driver's tests have an +/// implementation that adds extensions. +#[cfg(test)] +pub(crate) mod plain_fixture { + use core::any::Any; + use std::sync::Arc; + + use async_trait::async_trait; + use error_stack::Report; + use serde::Deserialize; + use serde_json::{Map, Value}; + + use crate::auction::demand::{ + CONSERVATIVE_LANGUAGE_MAX_BYTES, CompiledDemand, DemandFieldPolicy, DemandImplementation, + DemandResponse, DemandTimeoutDefault, ProviderAuctionInput, RegsPolicy, RequestExtensions, + accept_endpoint, + }; + use crate::auction::types::AuctionResponse; + use crate::error::TrustedServerError; + use crate::platform::PlatformResponse; + + /// The name an `implementation` line gives the stand-in. + pub(crate) const MODULE: &str = "auction.plain-fixture"; + + /// The stand-in demand implementation. + pub(crate) static DEMAND: DemandImplementation = DemandImplementation { + id: MODULE, + default_timeout: DemandTimeoutDefault::Auction, + allows_all_eligible: true, + serves_stored_requests: false, + canonicalize_endpoint: accept_endpoint, + compile, + }; + + /// One compiled stand-in source. + #[derive(Debug, Clone, Default)] + pub(crate) struct PlainDemand { + request_ext: Map, + imp_ext: Map, + } + + #[derive(Debug, Deserialize, Default)] + #[serde(deny_unknown_fields)] + struct PlainSettings { + #[serde(default)] + request_ext: Map, + #[serde(default)] + imp_ext: Map, + } + + fn compile( + settings: &Map, + ) -> Result, Report> { + let settings = + PlainSettings::deserialize(Value::Object(settings.clone())).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("invalid `{MODULE}` settings: {error}"), + }) + })?; + Ok(Arc::new(PlainDemand { + request_ext: settings.request_ext, + imp_ext: settings.imp_ext, + })) + } + + #[async_trait(?Send)] + impl CompiledDemand for PlainDemand { + fn field_policy(&self) -> DemandFieldPolicy { + DemandFieldPolicy { + language_max_bytes: Some(CONSERVATIVE_LANGUAGE_MAX_BYTES), + regs: RegsPolicy::Jurisdiction, + accept_json: true, + ..DemandFieldPolicy::default() + } + } + + fn augment_request( + &self, + extensions: &mut RequestExtensions<'_>, + _input: &ProviderAuctionInput, + ) -> Result<(), Report> { + *extensions.request = (!self.request_ext.is_empty()).then(|| self.request_ext.clone()); + for impression in &mut extensions.impressions { + *impression.ext = (!self.imp_ext.is_empty()).then(|| self.imp_ext.clone()); + } + Ok(()) + } + + async fn parse_response( + &self, + context: DemandResponse<'_>, + response: PlatformResponse, + ) -> Result> { + crate::auction::openrtb::parse_openrtb_response(context, response).await + } + + fn as_any(&self) -> &dyn Any { + self + } + } +} diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 4069157068..db7818b4d8 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,203 @@ 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, + /// The payload key the code that built the descriptor said holds the + /// identifier the renderer picks its bid by. A statement about the + /// payload and no part of what is sent. + #[serde(skip)] + bid_id_key: Option<&'static str>, } 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, + bid_id_key: None, + }) + } + + /// 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) + } + + /// States that the payload key `key` holds the identifier this renderer + /// picks its bid by. + /// + /// The statement is the builder's own about its payload. A descriptor + /// built without it has no such identifier, whatever its payload holds, + /// so a key of the same name under another renderer's type tag is not + /// read. + #[must_use] + pub fn picking_bid_by(mut self, key: &'static str) -> Self { + self.bid_id_key = Some(key); + self + } + + /// The identifier the renderer picks its bid by, when the code that built + /// the descriptor said which payload key holds it. + /// + /// # Examples + /// + /// ``` + /// use serde_json::json; + /// use trusted_server_core::auction::types::BidRenderer; + /// + /// let payload = json!({ "bidId": "fictional-bid-id" }); + /// let unstated = BidRenderer::new("example", payload.clone()) + /// .expect("should accept an object payload"); + /// assert_eq!(unstated.bid_id(), None); + /// + /// let stated = BidRenderer::new("example", payload) + /// .expect("should accept an object payload") + /// .picking_bid_by("bidId"); + /// assert_eq!(stated.bid_id(), Some("fictional-bid-id")); + /// ``` + #[must_use] + pub fn bid_id(&self) -> Option<&str> { + self.payload + .get(self.bid_id_key?) + .and_then(serde_json::Value::as_str) } } @@ -599,59 +749,177 @@ mod tests { ); } - #[test] - fn aps_renderer_serializes_to_versioned_camel_case_contract() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + /// A provider's own payload type, as an implementation in a crate of its + /// own defines one. + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct ExampleRenderer { + version: u8, + bid_id: String, + creative_url: String, + } + + const EXAMPLE_RENDERER_TYPE: &str = "example"; + + fn example_descriptor() -> ExampleRenderer { + ExampleRenderer { 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 serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); + #[test] + fn renderer_serializes_flat_under_its_type_tag() { + let renderer = BidRenderer::from_typed(EXAMPLE_RENDERER_TYPE, &example_descriptor()) + .expect("should build renderer descriptor"); assert_eq!( - serialized, + serde_json::to_value(&renderer).expect("should serialize renderer"), json!({ - "type": "aps", + "type": "example", "version": 1, - "accountId": "example-account-id", "bidId": "fictional-bid-id", - "creativeId": "fictional-creative-id", - "tagType": "iframe", - "creativeUrl": "https://creative.example/render", - "aaxResponse": "base64-data", - "width": 300, - "height": 250 + "creativeUrl": "https://creative.example/render" }), - "should match renderer wire contract" + "should carry the tag beside the payload's own keys" ); } #[test] - fn aps_renderer_omits_absent_creative_id() { - let renderer = BidRenderer::Aps(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, - }); + fn renderer_round_trips_through_its_wire_form() { + let descriptor = example_descriptor(); + let renderer = BidRenderer::from_typed(EXAMPLE_RENDERER_TYPE, &descriptor) + .expect("should build renderer descriptor"); + + 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(), + EXAMPLE_RENDERER_TYPE, + "should round-trip the renderer type tag" + ); + assert_eq!( + restored + .payload_as::(EXAMPLE_RENDERER_TYPE) + .expect("should deserialize the payload"), + descriptor, + "should round-trip the provider payload" + ); + } + + #[test] + fn renderer_payload_is_hidden_from_a_different_type_tag() { + let renderer = BidRenderer::new(EXAMPLE_RENDERER_TYPE, json!({ "version": 1 })) + .expect("should build renderer descriptor"); + + assert!( + renderer.payload_as::("other").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 = example_descriptor(); + let renderer = BidRenderer::from_typed(EXAMPLE_RENDERER_TYPE, &descriptor) + .expect("should build renderer descriptor"); + + assert_eq!( + renderer + .payload_field(EXAMPLE_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(EXAMPLE_RENDERER_TYPE, "bidId") + .and_then(serde_json::Value::as_str), + renderer + .payload_as::(EXAMPLE_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(EXAMPLE_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( + EXAMPLE_RENDERER_TYPE, + json!({ "bidId": "fictional-bid-id" }), + ) + .expect("should build renderer descriptor"); + + assert!( + renderer.payload_field("other", "bidId").is_none(), + "should refuse a field requested under a different tag" + ); + } + + #[test] + fn renderer_names_the_bid_it_picks_only_when_its_builder_said_which_key_holds_it() { + let unstated = BidRenderer::from_typed(EXAMPLE_RENDERER_TYPE, &example_descriptor()) + .expect("should build renderer descriptor"); + assert_eq!( + unstated.bid_id(), + None, + "should name no bid for a descriptor whose builder made no statement" + ); + + let stated = unstated.clone().picking_bid_by("bidId"); + assert_eq!( + stated.bid_id(), + Some("fictional-bid-id"), + "should read the key the builder stated" + ); + assert_eq!( + stated.clone().bid_id(), + Some("fictional-bid-id"), + "should keep the statement when the descriptor is cloned" + ); + assert_eq!( + serde_json::to_value(&stated).expect("should serialize renderer"), + serde_json::to_value(&unstated).expect("should serialize renderer"), + "should send the same bytes with or without the statement" + ); - let serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); + let absent = BidRenderer::new(EXAMPLE_RENDERER_TYPE, json!({ "version": 1 })) + .expect("should build renderer descriptor") + .picking_bid_by("bidId"); + assert_eq!( + absent.bid_id(), + None, + "should name no bid when the payload does not carry the stated key" + ); + } + #[test] + fn renderer_rejects_a_payload_that_is_not_an_object() { + assert!( + BidRenderer::new(EXAMPLE_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!( - serialized.get("creativeId").is_none(), - "should omit absent creative ID" + BidRenderer::new( + EXAMPLE_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 56171e1ea5..9a7f5ab019 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -1,20 +1,22 @@ //! 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, BTreeSet}; -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] modules = [...]` 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 `[ad-server] module = \"\"` and give it its settings in `[ad-server.]`, as the configuration rules describe"; + +use crate::provider_table::SectionModules; pub use crate::auction::plan::{ - BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, + BidderId, BidderRouteConfig, NotificationConfig, ProviderId, RoutingMode, }; /// Auction orchestration configuration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] pub struct AuctionConfig { /// Enable the auction orchestrator #[serde(default)] @@ -49,18 +51,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 `[ad-server]` 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")] @@ -68,7 +97,10 @@ pub struct AuctionConfig { pub timeout_ms: u32, /// KV store name for creative storage (deprecated: creatives are now delivered inline) - #[serde(default = "default_creative_store")] + #[serde( + default = "default_creative_store", + serialize_with = "crate::redacted::sensitive" + )] pub creative_store: String, /// Keys allowed in the auction request context map. @@ -79,6 +111,11 @@ pub struct AuctionConfig { /// deduplicated and serialized in sorted order so config hashes are stable. #[serde(default = "default_allowed_context_keys")] pub allowed_context_keys: BTreeSet, + + /// The modules this section selects, with each one's settings in the + /// table at its name. + #[serde(flatten)] + pub modules: SectionModules, } impl Default for AuctionConfig { @@ -87,47 +124,47 @@ 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: BTreeSet::new(), + modules: SectionModules::default(), } } } -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 { @@ -159,36 +196,6 @@ fn default_allowed_context_keys() -> BTreeSet { BTreeSet::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::*; @@ -283,35 +290,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] modules"), + "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("[ad-server] module"), + "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": { "module": "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 e48f5c2f03..8dfbaaf794 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -11,48 +11,16 @@ 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::module::{HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_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::IntegrationBuilder; +use crate::settings::{AssetOriginAuth, Ec, MODULE_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. /// @@ -116,10 +84,34 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { } } +/// The builders [`TrustedServerAppConfig`] validates against besides core's +/// own, set once by the tool that validates. +static DEPLOY_INTEGRATIONS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Registers the integration builders [`TrustedServerAppConfig`] validates +/// against besides core's own. +/// +/// `EdgeZero` validates an app config through the [`Validate`] trait, which +/// takes no arguments, so a tool that validates a deployment's settings +/// registers that deployment's builders here before it validates anything. +/// The first call wins and a later one changes nothing. +pub fn register_deploy_integrations(builders: Vec) { + let _ = DEPLOY_INTEGRATIONS.set(builders); +} + +/// The builders a tool registered for deploy validation, or none. +fn deploy_integrations() -> &'static [IntegrationBuilder] { + DEPLOY_INTEGRATIONS.get().map_or(&[], Vec::as_slice) +} + impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = self.settings.validate().err().unwrap_or_default(); - if let Err(report) = validate_settings_for_deploy(&self.settings) { + remove_labeled_module_secret_errors(&mut errors, &self.settings.ec); + if let Err(report) = + validate_settings_for_deploy_with(&self.settings, deploy_integrations()) + { errors.add( DEPLOY_VALIDATION_FIELD, report_to_validation_error(&report, "trusted_server_deploy_validation"), @@ -133,6 +125,101 @@ impl Validate for TrustedServerAppConfig { } } +/// Removes the passphrase checks on Edge Cookie module 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_module_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { + let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { + return; + }; + let labeled = ec + .module_blocks + .hmac_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HMAC_MODULE_KEY) + .chain( + ec.module_blocks + .host_signals_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HOST_SIGNALS_MODULE_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 module block that configures a + /// module 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 modules' blocks has when its name + /// is its implementation. The same module under a label of the + /// operator's choosing holds that secret at `ec.(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut table = ProviderTable::::default(); + while let Some(key) = map.next_key::()? { + let value = map.next_value::()?; + if key == S::KEY { + table.selected = S::from_value(value).map_err(de::Error::custom)?; + continue; + } + if key == "provider" { + return Err(de::Error::custom(format!( + "`provider` is `{}` here. Name what runs with `{}`", + S::KEY, + S::KEY + ))); + } + match value { + Value::Object(settings) => { + table.tables.insert(key, settings); + } + _ => { + return Err(de::Error::custom(format!( + "`{key}` is not a setting of this table. Only `{}` and named provider tables belong here", + S::KEY + ))); + } + } + } + Ok(table) + } + } + + deserializer.deserialize_map(TableVisitor(core::marker::PhantomData)) + } +} + +impl Serialize for ProviderTable { + fn serialize(&self, serializer: Z) -> Result + where + Z: Serializer, + { + let selection = self.selected.to_value(); + let mut map = + serializer.serialize_map(Some(self.tables.len() + usize::from(selection.is_some())))?; + if let Some(selection) = selection { + map.serialize_entry(S::KEY, &selection)?; + } + for (name, settings) in &self.tables { + map.serialize_entry(name, settings)?; + } + map.end() + } +} + +/// Which key a section's selection was written under. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionKey { + Module, + Modules, +} + +impl SelectionKey { + const fn as_str(self) -> &'static str { + match self { + Self::Module => "module", + Self::Modules => "modules", + } + } +} + +/// The modules one section selects, and each one's settings table. +/// +/// `modules` selects several, in the order they run, and `module` one, and a +/// section carries at most one of the two. Every other table in the section is +/// the settings of a selected module, at the name written in the selection +/// with `.` between its parts, so `modules = ["testing.example"]` reads its +/// settings from `[
.testing.example]`. +#[derive(Clone, Default, PartialEq)] +pub struct SectionModules { + key: Option, + selected: Vec, + tables: Map, +} + +impl fmt::Debug for SectionModules { + // The tables can hold secrets, so only the selection and the tables' + // names are shown. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SectionModules") + .field("selected", &self.selected) + .field("tables", &self.tables.keys().collect::>()) + .finish() + } +} + +impl SectionModules { + /// Reads a section's entries, in a section that holds nothing else. + /// + /// # Errors + /// + /// Returns a message for a selection of the wrong shape, for both keys at + /// once, and for an entry that is neither a selection nor a table. + pub fn from_entries(entries: Map) -> Result { + let mut section = Self::default(); + for (key, value) in entries { + match key.as_str() { + "module" | "modules" => { + let key = if key == "module" { + SelectionKey::Module + } else { + SelectionKey::Modules + }; + if section.key.is_some_and(|written| written != key) { + return Err( + "carries both `module` and `modules`. Write `module` for one or \ + `modules` for several" + .to_owned(), + ); + } + section.key = Some(key); + section.selected = match key { + SelectionKey::Module => { + as ProviderSelection>::from_value(value)? + .into_iter() + .collect() + } + SelectionKey::Modules => { + as ProviderSelection>::from_value(value)? + } + }; + } + _ => match value { + Value::Object(_) => { + section.tables.insert(key, value); + } + _ => { + return Err(format!( + "has `{key}`, which is not a setting it reads. Only `module` or \ + `modules` and the settings tables of the modules they select \ + belong there" + )); + } + }, + } + } + Ok(section) + } + + /// The selected names, as written, in the order they run. + #[must_use] + pub fn selected(&self) -> &[String] { + &self.selected + } + + /// Whether the section selects nothing and holds no table. + #[must_use] + pub fn is_unset(&self) -> bool { + self.selected.is_empty() && self.tables.is_empty() + } + + /// The settings table of a name as written in the selection, found by + /// its parts. + #[must_use] + pub fn settings_of(&self, written: &str) -> Option<&Map> { + let mut parts = written.split('.'); + let mut table = self.tables.get(parts.next()?)?.as_object()?; + for part in parts { + table = table.get(part)?.as_object()?; + } + Some(table) + } + + /// The settings table of a name as written in the selection, to change. + pub fn settings_of_mut(&mut self, written: &str) -> Option<&mut Map> { + let mut parts = written.split('.'); + let mut table = self.tables.get_mut(parts.next()?)?.as_object_mut()?; + for part in parts { + table = table.get_mut(part)?.as_object_mut()?; + } + Some(table) + } + + /// Selects `written` and stores its settings table, which is what writing + /// both in a document does. + pub fn insert(&mut self, written: &str, settings: Map) { + if !self.selected.iter().any(|name| name == written) { + self.selected.push(written.to_owned()); + } + if self.key.is_none() { + self.key = Some(SelectionKey::Modules); + } + let mut parts: Vec<&str> = written.split('.').collect(); + let last = parts.pop().unwrap_or(written); + let mut table = &mut self.tables; + for part in parts { + let entry = table + .entry(part.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + let Value::Object(next) = entry else { + return; + }; + table = next; + } + table.insert(last.to_owned(), Value::Object(settings)); + } + + /// Selects `written` with no settings table of its own. + pub fn select(&mut self, written: &str) { + if !self.selected.iter().any(|name| name == written) { + self.selected.push(written.to_owned()); + } + if self.key.is_none() { + self.key = Some(SelectionKey::Modules); + } + } + + /// Stops selecting `written` and drops its table. + pub fn remove(&mut self, written: &str) { + self.selected.retain(|name| name != written); + let mut parts: Vec<&str> = written.split('.').collect(); + let Some(last) = parts.pop() else { + return; + }; + let mut table = &mut self.tables; + for part in parts { + let Some(Value::Object(next)) = table.get_mut(part) else { + return; + }; + table = next; + } + table.remove(last); + } + + /// Stops selecting everything and drops every table. + pub fn clear(&mut self) { + self.selected.clear(); + self.tables.clear(); + } + + /// Checks the rules every section's selection follows, naming the section. + /// + /// # Errors + /// + /// Returns a message for a name that is not a module name, a name selected + /// twice, and a table that is not the settings of a selected name. + pub fn validate(&self, section: &str) -> Result<(), String> { + let key = self.key.unwrap_or(SelectionKey::Modules).as_str(); + for (position, name) in self.selected.iter().enumerate() { + if !crate::module_name::is_valid(name) { + return Err(format!( + "[{section}] {key} names `{name}`, which is not a module name, being parts \ + joined by `.`, each of lower case letters, digits, `_` or `-`" + )); + } + if self.selected[..position].contains(name) { + return Err(format!("[{section}] {key} names `{name}` more than once")); + } + } + check_tables(section, key, &self.selected, &self.tables, "") + } +} + +/// Checks that every table under `prefix` is a selected name's settings, or +/// lies on the way to one. +fn check_tables( + section: &str, + key: &str, + selected: &[String], + tables: &Map, + prefix: &str, +) -> Result<(), String> { + for (name, value) in tables { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + if selected.contains(&path) { + continue; + } + let leads_to_one = selected + .iter() + .any(|written| written.starts_with(&format!("{path}."))); + match value { + Value::Object(inner) if leads_to_one => { + check_tables(section, key, selected, inner, &path)?; + } + _ => { + return Err(format!( + "[{section}.{path}] is configured, but [{section}] {key} does not select \ + `{path}`. Add it to {key}, or remove the table" + )); + } + } + } + Ok(()) +} + +impl<'de> Deserialize<'de> for SectionModules { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let entries = Map::::deserialize(deserializer)?; + Self::from_entries(entries) + .map_err(|message| serde::de::Error::custom(format!("the section {message}"))) + } +} + +impl Serialize for SectionModules { + fn serialize(&self, serializer: Z) -> Result + where + Z: Serializer, + { + let selection = + (!self.selected.is_empty()).then(|| match self.key.unwrap_or(SelectionKey::Modules) { + SelectionKey::Module if self.selected.len() == 1 => ( + SelectionKey::Module.as_str(), + Value::String(self.selected[0].clone()), + ), + _ => ( + SelectionKey::Modules.as_str(), + Value::Array(self.selected.iter().cloned().map(Value::String).collect()), + ), + }); + let mut map = + serializer.serialize_map(Some(self.tables.len() + usize::from(selection.is_some())))?; + if let Some((key, value)) = selection { + map.serialize_entry(key, &value)?; + } + for (name, table) in &self.tables { + map.serialize_entry(name, table)?; + } + map.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn list(json: Value) -> Result { + serde_json::from_value(json) + } + + #[test] + fn reads_the_selection_and_named_tables() { + let table = list(serde_json::json!({ + "modules": ["main_source", "second_source"], + "main_source": { "implementation": "exchange", "endpoint": "https://exchange.example" } + })) + .expect("should read a module list"); + + assert_eq!(table.selected(), vec!["main_source", "second_source"]); + assert_eq!(table.implementation_of("main_source"), "exchange"); + assert_eq!( + table.implementation_of("second_source"), + "second_source", + "a name with no table should be its own implementation" + ); + assert!( + !table + .settings_of("main_source") + .contains_key(IMPLEMENTATION_KEY), + "settings should not carry the implementation line" + ); + table.validate("demand").expect("should accept the table"); + } + + #[test] + fn rejects_a_table_that_is_not_selected() { + let table = list(serde_json::json!({ + "modules": ["second_source"], + "main_source": { "endpoint": "https://exchange.example" } + })) + .expect("should read the table"); + + let error = table + .validate("demand") + .expect_err("should reject an unselected table"); + assert!( + error.contains("[demand.main_source]") && error.contains("does not select"), + "should name the table and the fix: {error}" + ); + } + + #[test] + fn rejects_a_name_selected_twice() { + let table = list(serde_json::json!({ "modules": ["second_source", "second_source"] })) + .expect("should read the table"); + + let error = table + .validate("demand") + .expect_err("should reject a duplicate"); + assert!(error.contains("more than once"), "should say why: {error}"); + } + + #[test] + fn rejects_names_that_are_not_snake_case() { + for name in ["main-source", "MainSource", "1source", ""] { + let table = list(serde_json::json!({ "modules": [name] })).expect("should read"); + let error = table + .validate("demand") + .expect_err("should reject a name that is not snake_case"); + assert!( + error.contains("snake_case"), + "should say why for `{name}`: {error}" + ); + } + } + + #[test] + fn an_implementation_is_named_by_its_module_path() { + let table = list(serde_json::json!({ + "modules": ["main_source"], + "main_source": { "implementation": "auction.example-exchange" } + })) + .expect("should read the table"); + table + .validate("demand") + .expect("a module path should name an implementation"); + + for implementation in [ + serde_json::json!("Auction.Exchange"), + serde_json::json!("auction/exchange"), + serde_json::json!("auction..exchange"), + serde_json::json!(7), + ] { + let table = list(serde_json::json!({ + "modules": ["main_source"], + "main_source": { "implementation": implementation } + })) + .expect("should read the table"); + let error = table + .validate("demand") + .expect_err("should refuse an implementation that is not a module name"); + assert!( + error.contains("[demand.main_source] implementation must be a module name"), + "should say why for {implementation}: {error}" + ); + } + } + + #[test] + fn rejects_a_value_that_is_not_a_table() { + let error = list(serde_json::json!({ "modules": ["second_source"], "timeout_ms": 500 })) + .expect_err("should reject a stray value"); + assert!( + error.to_string().contains("`timeout_ms` is not a setting"), + "should name the stray key: {error}" + ); + } + + #[test] + fn a_choice_takes_one_name_and_a_list_takes_a_list() { + let choice: ProviderChoice = + serde_json::from_value(serde_json::json!({ "module": "picker" })) + .expect("should read a single name"); + assert_eq!(choice.selected(), vec!["picker"]); + + serde_json::from_value::(serde_json::json!({ "module": ["a"] })) + .expect_err("a single-module type should refuse a list"); + serde_json::from_value::(serde_json::json!({ "modules": "a" })) + .expect_err("a list type should refuse a single name"); + } + + #[test] + fn refuses_the_previous_selection_key() { + let error = list(serde_json::json!({ "provider": ["main_source"] })) + .expect_err("should refuse the key the selection was written under before"); + assert!( + error.to_string().contains("`provider` is `modules` here"), + "the refusal names the key to use: {error}" + ); + + let error = + serde_json::from_value::(serde_json::json!({ "provider": "a" })) + .expect_err("should refuse the previous key for a type that runs one"); + assert!( + error.to_string().contains("`provider` is `module` here"), + "the refusal names the key to use: {error}" + ); + } + + #[test] + fn round_trips_through_serialization() { + let table = list(serde_json::json!({ + "modules": ["main_source"], + "main_source": { "implementation": "exchange", "timeout_ms": 900 } + })) + .expect("should read the table"); + + let value = serde_json::to_value(&table).expect("should serialize"); + let again: ProviderList = serde_json::from_value(value).expect("should read it back"); + assert_eq!(table, again); + } +} + +#[cfg(test)] +mod section_tests { + use super::SectionModules; + use serde_json::{Map, Value, json}; + + fn read(json: Value) -> Result { + serde_json::from_value(json) + } + + #[test] + fn reads_the_selection_and_the_selected_tables() { + let section = read(json!({ + "modules": ["didomi", "sourcepoint"], + "didomi": { "api_key": "k" } + })) + .expect("should read a section"); + assert_eq!(section.selected(), ["didomi", "sourcepoint"]); + assert_eq!( + section.settings_of("didomi").and_then(|t| t.get("api_key")), + Some(&json!("k")) + ); + assert!(section.settings_of("sourcepoint").is_none()); + section.validate("cmp").expect("should pass validation"); + } + + #[test] + fn module_selects_one_and_a_list_there_is_refused() { + let section = read(json!({ "module": "didomi" })).expect("should read one"); + assert_eq!(section.selected(), ["didomi"]); + let error = read(json!({ "module": ["didomi"] })).expect_err("should refuse a list"); + assert!(error.to_string().contains("`module` names one"), "{error}"); + } + + #[test] + fn both_keys_at_once_are_refused() { + let error = + read(json!({ "module": "a", "modules": ["b"] })).expect_err("should refuse both keys"); + assert!( + error + .to_string() + .contains("carries both `module` and `modules`"), + "{error}" + ); + } + + #[test] + fn a_value_that_is_neither_selection_nor_table_is_refused() { + let error = read(json!({ "modules": ["a"], "enabled": true })) + .expect_err("should refuse a stray value"); + assert!( + error + .to_string() + .contains("has `enabled`, which is not a setting it reads"), + "{error}" + ); + } + + #[test] + fn a_table_no_selection_names_is_refused_by_validation() { + let section = read(json!({ "modules": ["a"], "b": {} })).expect("should read"); + let error = section + .validate("cmp") + .expect_err("should refuse the stray table"); + assert_eq!( + error, + "[cmp.b] is configured, but [cmp] modules does not select `b`. Add it to modules, or remove the table" + ); + } + + #[test] + fn a_name_written_with_dots_reads_a_nested_table() { + let section = read(json!({ + "modules": ["google", "google.diagnostics"], + "google": { "timeout_ms": 5, "diagnostics": { "overlay": true } } + })) + .expect("should read"); + section + .validate("ad-tag") + .expect("the nested table is a selected name's"); + assert_eq!( + section + .settings_of("google.diagnostics") + .and_then(|t| t.get("overlay")), + Some(&json!(true)) + ); + } + + #[test] + fn a_name_that_is_not_a_module_name_or_is_repeated_is_refused() { + let section = read(json!({ "modules": ["Didomi"] })).expect("should read"); + let error = section.validate("cmp").expect_err("should refuse the name"); + assert!(error.contains("is not a module name"), "{error}"); + let section = read(json!({ "modules": ["a", "a"] })).expect("should read"); + let error = section + .validate("cmp") + .expect_err("should refuse the repeat"); + assert_eq!(error, "[cmp] modules names `a` more than once"); + } + + #[test] + fn insert_select_remove_and_clear_keep_the_selection_and_tables_together() { + let mut section = SectionModules::default(); + assert!(section.is_unset()); + let mut settings = Map::new(); + settings.insert("api_key".to_owned(), json!("k")); + section.insert("didomi", settings); + section.select("osano"); + assert_eq!(section.selected(), ["didomi", "osano"]); + assert!(section.settings_of("didomi").is_some()); + section.remove("didomi"); + assert_eq!(section.selected(), ["osano"]); + assert!(section.settings_of("didomi").is_none()); + section.clear(); + assert!(section.is_unset()); + } + + #[test] + fn round_trips_through_serialization() { + let section = + read(json!({ "module": "didomi", "didomi": { "api_key": "k" } })).expect("should read"); + let value = serde_json::to_value(§ion).expect("should serialize"); + assert_eq!( + value, + json!({ "module": "didomi", "didomi": { "api_key": "k" } }) + ); + let several = read(json!({ "modules": ["a", "b"] })).expect("should read"); + assert_eq!( + serde_json::to_value(&several).expect("should serialize"), + json!({ "modules": ["a", "b"] }) + ); + } +} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 6a30645aba..e7160087a3 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -21,7 +21,7 @@ use crate::constants::{ HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR, }; use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor}; -use crate::edge_cookie::get_ec_id; +use crate::edge_cookie::recognized_ec_id; use crate::error::TrustedServerError; use crate::platform::{ DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, @@ -107,7 +107,10 @@ const ASSET_PROXY_FORWARD_HEADERS: [header::HeaderName; 11] = [ /// All three take effect against the serving origin, so leaving them in place /// would let an upstream write first-party cookies, pin HSTS, or clear the /// publisher's site data. -pub(crate) const FIRST_PARTY_PASSTHROUGH_STRIP_HEADERS: [&str; 3] = +/// +/// Public so an integration crate that passes an upstream body through strips +/// the same three. +pub const FIRST_PARTY_PASSTHROUGH_STRIP_HEADERS: [&str; 3] = ["set-cookie", "strict-transport-security", "clear-site-data"]; /// Cache-control value used when asset proxy responses must not be stored. @@ -818,7 +821,7 @@ pub async fn proxy_request( })?; if forward_ec_id { - append_ec_id(&req, &mut target_url_parsed); + append_ec_id(settings, services, &req, &mut target_url_parsed); } proxy_with_redirects( @@ -1221,8 +1224,19 @@ fn upsert_ec_query_param(url: &mut url::Url, ec_id: &str) { url.set_query(Some(&serializer.finish())); } -fn append_ec_id(req: &Request, target_url_parsed: &mut url::Url) { - let ec_id_param = match get_ec_id(req) { +/// Forwards the request's Edge Cookie identifier to the outbound target URL. +/// +/// Only an identifier the selected module recognizes is forwarded. A value +/// carrying another deployment's module code, and any value at all in a +/// stateless deployment, is withheld, so nothing this deployment did not issue +/// reaches the origin. +fn append_ec_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, + target_url_parsed: &mut url::Url, +) { + let ec_id_param = match recognized_ec_id(settings, services, req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -1259,7 +1273,11 @@ fn is_host_permitted>(allowed_domains: &[S], host: &str) -> bool { /// /// Comparison is case-insensitive. The wildcard check requires a dot boundary, /// so `"*.example.com"` does **not** match `"evil-example.com"`. -pub(crate) fn is_host_allowed(host: &str, pattern: &str) -> bool { +/// +/// Public so an integration crate matches a host against an operator's +/// allowed domains the way the proxy does. +#[must_use] +pub fn is_host_allowed(host: &str, pattern: &str) -> bool { let host = host.to_ascii_lowercase(); let pattern = pattern.to_ascii_lowercase(); @@ -1615,7 +1633,7 @@ pub async fn handle_first_party_proxy( /// Returns an error if the signed target cannot be reconstructed or validation fails. pub async fn handle_first_party_click( settings: &Settings, - _services: &RuntimeServices, + services: &RuntimeServices, req: Request, ) -> Result, Report> { let SignedTarget { @@ -1624,7 +1642,10 @@ pub async fn handle_first_party_click( had_params, } = reconstruct_and_validate_signed_target(settings, &req.uri().to_string())?; - let ec_id = match get_ec_id(&req) { + // The redirect target is a third party's URL, so only an identifier the + // selected module recognizes is added to it. A stateless deployment adds + // nothing. + let ec_id = match recognized_ec_id(settings, services, &req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -2238,16 +2259,17 @@ mod tests { use super::{ AssetProxyCachePolicy, IMAGE_FALLBACK_CONTENT_TYPE, ProxyRequestConfig, - SUPPORTED_ENCODINGS, asset_origin_host_header, asset_path_skips_image_optimizer, - build_asset_proxy_target_url, handle_asset_proxy_request, handle_first_party_click, - handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, - is_host_allowed, is_host_permitted, proxy_request, rebuild_response_with_body, - reconstruct_and_validate_signed_target, stream_asset_body, + SUPPORTED_ENCODINGS, append_ec_id, asset_origin_host_header, + asset_path_skips_image_optimizer, build_asset_proxy_target_url, handle_asset_proxy_request, + handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, + handle_first_party_proxy_sign, is_host_allowed, is_host_permitted, proxy_request, + rebuild_response_with_body, reconstruct_and_validate_signed_target, stream_asset_body, }; use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; + use crate::platform::RuntimeServices; use crate::platform::test_support::{ HashMapSecretStore, StubHttpClient, build_services_with_http_client, build_services_with_secret_and_http_client, noop_services, @@ -2267,6 +2289,7 @@ mod tests { use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; use error_stack::Report; + use http::Request; use http::{HeaderValue, Method, Request as HttpRequest, Response, StatusCode, header}; #[test] @@ -2989,7 +3012,8 @@ mod tests { ); req.headers_mut().insert( crate::constants::HEADER_X_TS_EC, - HeaderValue::from_static("ec-123"), + HeaderValue::from_str(&recognized_hmac_ec_id()) + .expect("should build EC header value"), ); let resp = handle_first_party_click(&settings, &noop_services(), req) @@ -3007,11 +3031,166 @@ mod tests { .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect(); assert_eq!(pairs.remove("foo").as_deref(), Some("1")); - assert_eq!(pairs.remove("ts-ec").as_deref(), Some("ec-123")); + assert_eq!( + pairs.remove("ts-ec").as_deref(), + Some(recognized_hmac_ec_id().as_str()) + ); assert!(pairs.is_empty()); }); } + /// An identifier the built-in HMAC module, the module + /// `create_test_settings` selects, recognizes as its own. + fn recognized_hmac_ec_id() -> String { + format!("hmac~{}", crate::test_support::tests::VALID_SYNTHETIC_ID) + } + + /// A well-formed identifier carrying a module code no deployment here + /// reads, the shape a partner or another deployment would hand back. + const FOREIGN_CODED_EC_ID: &str = "zz00~someone-elses-identifier"; + + fn stateless_settings() -> Settings { + let mut settings = create_test_settings(); + settings.ec.module = None; + settings.ec.module_blocks.clear(); + settings + } + + fn signed_click_request(settings: &Settings, ec_id: &str) -> Request { + let tsurl = "https://cdn.example/a.png"; + let full = format!("{tsurl}?foo=1"); + let sig = crate::http_util::compute_encrypted_sha256_token(settings, &full); + let mut req = build_http_request( + Method::GET, + format!( + "https://edge.example/first-party/click?tsurl={}&foo=1&tstoken={}", + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), + sig + ), + ); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC, + HeaderValue::from_str(ec_id).expect("should build EC header value"), + ); + req + } + + fn click_ts_ec_param( + settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> Option { + let resp = futures::executor::block_on(handle_first_party_click(settings, services, req)) + .expect("should redirect"); + let loc = resp + .headers() + .get(header::LOCATION) + .and_then(|h| h.to_str().ok()) + .expect("Location header should be present and valid") + .to_owned(); + url::Url::parse(&loc) + .expect("Location should be a valid URL") + .query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + + #[test] + fn click_withholds_an_ec_id_the_module_does_not_recognize() { + let settings = create_test_settings(); + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, FOREIGN_CODED_EC_ID), + ); + + assert_eq!( + param, None, + "a value carrying another deployment's module code should not reach the click target" + ); + } + + #[test] + fn click_withholds_every_ec_id_in_a_stateless_deployment() { + let settings = stateless_settings(); + // The value is one the built-in module would recognize, so only the + // absence of a selected module can withhold it. + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, &recognized_hmac_ec_id()), + ); + + assert_eq!( + param, None, + "a deployment that creates no identifier should hand none to the click target" + ); + } + + #[test] + fn append_ec_id_forwards_only_what_the_module_recognizes() { + let settings = create_test_settings(); + let services = noop_services(); + let recognized = recognized_hmac_ec_id(); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + Some(recognized.clone()), + "the deployment's own identifier should still reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(FOREIGN_CODED_EC_ID), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a foreign module code should not reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &stateless_settings(), + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a stateless deployment should forward nothing to the origin" + ); + } + + fn request_with_ec_cookie(ec_id: &str) -> Request { + let mut req = build_http_request(Method::GET, "https://edge.example/page"); + req.headers_mut().insert( + http::header::COOKIE, + HeaderValue::from_str(&format!("ts-ec={ec_id}")).expect("should build cookie header"), + ); + req + } + + fn ts_ec_param(url: &url::Url) -> Option { + url.query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + #[test] fn proxy_rebuild_adds_and_removes_params() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 682ecc0f15..4ef7d3bceb 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -51,12 +51,12 @@ use crate::auction::telemetry::{ emit_auction_events_best_effort_lazy, }; use crate::auction::types::{ - AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, + AuctionContext, AuctionRequest, Bid, BidRenderer, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; use crate::cache_policy::{ CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, }; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; use crate::cookies::template_cache_policy::{TemplateCookieDecision, evaluate_cookie_policy}; @@ -70,7 +70,8 @@ use crate::ec::{EcContext, EidSyncSource}; use crate::error::TrustedServerError; use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; -use crate::integrations::IntegrationRegistry; +use crate::integrations::{IntegrationRegistry, IntegrationRequestState}; +use crate::permissions::PermissionState; use crate::platform::{ GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, VarySpec, contains_publisher_esi_directive, @@ -526,8 +527,11 @@ fn encode_complete_body( /// Serves two types of bundles: /// - **Unified bundle** (`tsjs-unified.min.js`): core + immediate (non-deferred) /// integration modules. -/// - **Deferred module** (`tsjs-{id}.min.js`): a single self-contained IIFE for -/// modules loaded with `defer` (e.g., prebid). +/// - **Single module** (`tsjs-{id}.min.js`): a single self-contained IIFE for +/// modules loaded with `defer` (e.g., prebid) or registered as standalone. +/// +/// Every module comes from the registry's parts, so a module a registration +/// carries is served and versioned like a compile-time one. /// /// # Errors /// @@ -548,28 +552,28 @@ pub fn handle_tsjs_dynamic( if UNIFIED_FILENAMES.contains(&filename) { // Serve core + immediate modules (excludes deferred like prebid). - let module_ids = integration_registry.js_module_ids_immediate(); - let body = trusted_server_js::concatenate_modules(&module_ids); - let hash = trusted_server_js::concatenated_hash(&module_ids); + let parts = integration_registry.js_parts_immediate(); + let body = crate::tsjs_bundle::compose(&parts); + let hash = crate::tsjs_bundle::compose_hash(&parts); return Ok(serve_tsjs_static(req, &body, &hash, edge_header)); } - if let Some(module_id) = parse_single_module_filename(filename) { - // Deferred modules and the conditionally injected diagnostics module - // are served as content-addressed standalone assets. Delivery remains - // cookie-independent so the static response can stay publicly cached. - let deferred_ids = integration_registry.js_module_ids_deferred(); - let diagnostics_standalone = module_id - == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID - && integration_registry.integration_enabled(module_id); - if !deferred_ids.contains(&module_id) && !diagnostics_standalone { + if let Some(module_id) = parse_single_module_filename(filename, integration_registry) { + // Deferred modules and standalone modules are served as + // content-addressed single files. Delivery remains cookie-independent + // so the static response can stay publicly cached. + let deferred = integration_registry.js_module_ids_deferred(); + let standalone = integration_registry.js_standalone_ids(); + if !deferred.contains(&module_id) && !standalone.contains(&module_id) { return Ok(not_found_response()); } - if let (Some(content), Some(hash)) = ( - trusted_server_js::module_bundle(module_id), - trusted_server_js::single_module_hash(module_id), - ) { - return Ok(serve_tsjs_static(req, content, hash, edge_header)); + if let Some(part) = integration_registry.js_part(module_id) { + return Ok(serve_tsjs_static( + req, + part.source, + part.sha256, + edge_header, + )); } } @@ -605,21 +609,22 @@ fn request_version_hash(req: &Request) -> Option<&str> { }) } -/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`. +/// Extract a module ID from a single-module filename like `tsjs-sourcepoint.min.js`. /// -/// Returns `Some(&'static str)` if the filename matches a known JS module ID, -/// `None` otherwise. The caller must additionally verify that the module is -/// both deferred and enabled via the [`IntegrationRegistry`]. +/// Returns `Some(&'static str)` if the filename names a module the registry +/// serves somewhere (bundle, deferred or standalone), resolved through the +/// registry so a carried module is found. `None` otherwise. The caller must +/// additionally verify that the module is deferred or standalone. #[must_use] -fn parse_single_module_filename(filename: &str) -> Option<&'static str> { +fn parse_single_module_filename( + filename: &str, + registry: &IntegrationRegistry, +) -> Option<&'static str> { let stem = filename .strip_prefix("tsjs-") .and_then(|s| s.strip_suffix(".min.js").or_else(|| s.strip_suffix(".js")))?; - trusted_server_js::all_module_ids() - .iter() - .copied() - .find(|&id| id == stem) + registry.js_module_id(stem) } /// Parameters for processing response streaming. @@ -632,11 +637,13 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option<&'a str>, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, - suppress_datadome_client_side_tag: bool, - gpt_diagnostics: - Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>, + /// See [`HtmlStreamProcessorParams::request_state`]. + request_state: &'a IntegrationRequestState, /// See [`HtmlStreamProcessorParams::shared_template_authorized`]. shared_template_authorized: bool, /// See [`HtmlStreamProcessorParams::csp_nonce_observed`]. @@ -675,10 +682,10 @@ impl PublisherBodyProcessor { request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.clone(), + request_state: params.request_state.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), deferred_inline_marker: inline_seam_token @@ -759,10 +766,10 @@ fn process_response_streaming( request_scheme: params.request_scheme, settings: params.settings, integration_registry: params.integration_registry, + permissions_script: params.permissions_script.map(str::to_string), ad_slots_script: params.ad_slots_script.map(str::to_string), ad_bids_state: params.ad_bids_state.clone(), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.cloned(), + request_state: params.request_state.clone(), shared_template_authorized: params.shared_template_authorized, csp_nonce_observed: params.csp_nonce_observed.cloned(), deferred_inline_marker: None, @@ -1316,10 +1323,14 @@ struct HtmlStreamProcessorParams<'a> { request_scheme: &'a str, settings: &'a Settings, integration_registry: &'a IntegrationRegistry, + /// Head script carrying this request's permission state, or [`None`] under a + /// shared-template mode. See [`template_permissions_script`]. + permissions_script: Option, ad_slots_script: Option, ad_bids_state: Arc>>, - suppress_datadome_client_side_tag: bool, - gpt_diagnostics: Option, + /// What modules left on the request for their page hooks. See + /// [`template_request_state`]. + request_state: IntegrationRequestState, /// Whether a shared template was authorized for this response. /// /// Carried rather than re-derived so both seams see the same answer. See @@ -1331,26 +1342,22 @@ struct HtmlStreamProcessorParams<'a> { deferred_inline_marker: Option, } -/// The diagnostics decision the template may carry. +/// The request state a document's page hooks may read. /// -/// Diagnostics is request-scoped — activated by a cookie or query parameter, and -/// documented as an immutable per-request decision — so it must not reach a shared -/// template. +/// What a module leaves on a request is made for that one request, so it +/// must not reach a template that is stored and served to other readers. /// -/// It does not leak today even without this gate, but only by coincidence: -/// `requires_private_no_store()` is a strict superset of the conditions under which -/// a script is emitted, and that stamp lands before the template cache gate reads response -/// headers, so the gate refuses. Two independent conditions that happen to align, -/// with nothing enforcing the relationship. This makes the guarantee explicit; -/// `requires_private_no_store_is_a_superset_of_injection` keeps the coincidence as a -/// backstop if this gate is ever removed. -pub(crate) fn template_gpt_diagnostics( +/// [`handle_publisher_request`] already keeps a request that carries any off +/// the shared template path, so a shared mode never sees one today. This +/// makes the same guarantee where the document is built, so that neither +/// place depends on the other. +pub(crate) fn template_request_state( mode: AssemblyMode, - decision: Option, -) -> Option { + request_state: IntegrationRequestState, +) -> IntegrationRequestState { match mode { - AssemblyMode::Inline => decision, - AssemblyMode::Esi => None, + AssemblyMode::Inline => request_state, + AssemblyMode::Esi => IntegrationRequestState::default(), } } @@ -1472,6 +1479,20 @@ pub(crate) fn body_close_injection( } } +/// The head script this response's permission state belongs in, if any. +/// +/// Derived here rather than carried on [`OwnedProcessResponseParams`] so the state +/// has one representation on the request (the JSON) and the head-or-seam decision is +/// taken from the same effective mode both seams use. Under a shared-template mode +/// the answer is [`None`] and the seam carries the state instead. +fn permissions_script_for( + params: &OwnedProcessResponseParams, + settings: &Settings, +) -> Option { + let mode = effective_assembly_mode(settings, params.template_cache_key.is_some()); + template_permissions_script(mode, ¶ms.permissions_json) +} + fn deferred_inline_seam_token( settings: &Settings, shared_template_authorized: bool, @@ -1514,7 +1535,7 @@ fn create_html_stream_processor( _ => body_close_injection(assembly_mode, params.ad_slots_script.is_some()), }; - let gpt_diagnostics = template_gpt_diagnostics(assembly_mode, params.gpt_diagnostics); + let request_state = template_request_state(assembly_mode, params.request_state); // Only a response that can be stored has a consumer for the observation, so the // handlers are not registered for ordinary inline traffic. @@ -1524,11 +1545,11 @@ fn create_html_stream_processor( .flatten(); let config = config + .with_permissions_script(params.permissions_script) .with_ad_state(params.ad_slots_script, params.ad_bids_state) - .with_gpt_diagnostics(gpt_diagnostics) + .with_request_state(request_state) .with_body_close(body_close) - .with_csp_nonce_observer(csp_nonce_observed) - .with_datadome_client_tag_suppression(params.suppress_datadome_client_side_tag); + .with_csp_nonce_observer(csp_nonce_observed); Ok(create_html_processor(config)) } @@ -1699,6 +1720,14 @@ pub struct OwnedProcessResponseParams { /// /// Request-scoped, so it travels with the request rather than into the template. pub(crate) seam_ad_slots: Option, + /// This request's resolved permission state as page JSON, from + /// [`PermissionState::page_json`]. + /// + /// Carried as the JSON rather than as a rendered script because it is delivered in + /// two different places: the head under an inline response, and the `` seam + /// under a shared-template one. An empty string means no caller filled it in and + /// renders as the empty state. + pub(crate) permissions_json: String, /// Origin policy headers to store with the template and replay on a hit. pub(crate) policy_headers: Vec<(String, String)>, pub(crate) content_encoding: String, @@ -1719,11 +1748,8 @@ pub struct OwnedProcessResponseParams { pub(crate) dispatched_auction: Option, /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, - /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. - pub(crate) suppress_datadome_client_side_tag: bool, - /// Request-scoped conditional diagnostics delivery decision. - pub(crate) gpt_diagnostics: - Option, + /// What modules left on the request for their page hooks. + pub(crate) request_state: IntegrationRequestState, /// Set by the transform when the document carries a response-bound CSP nonce. /// /// `None` wherever no transform runs. Recorded by the HTML parser rather than @@ -2064,7 +2090,7 @@ fn assemble_if_shared( Ok((out, Some(AssemblyResponseState::ByteSeamFallback))) } -/// Fingerprint of settings, browser bundles, and the core implementation. +/// Fingerprint of settings, the browser modules a registry serves, and the core implementation. /// /// This intentionally over-invalidates. Trying to maintain a hand-written list already /// omitted publisher origin identity and creative-opportunity shaping fields. A digest of @@ -2074,20 +2100,30 @@ fn assemble_if_shared( /// files (including embedded head scripts), build logic, manifests, and the workspace /// lockfile when present, so implementation edits automatically invalidate templates. /// +/// The module digest covers the parts this registry can actually serve, carried modules +/// included, so a vendor crate that rebuilds its browser module moves the hash, and +/// templates keyed under the previous hash are no longer read. It also narrows the +/// module set from every module compiled into the binary to the ones this deployment can +/// serve. That is safe because a module this deployment never serves appears in no bundle +/// hash and no injected URL, so it cannot change what a template renders. +/// /// # Panics /// /// Does not panic: serializing the already-deserialized typed settings to a JSON value is /// infallible for this schema. -fn template_fingerprint(settings: &Settings) -> String { - template_fingerprint_with_build_digest(settings, TEMPLATE_BUILD_DIGEST) +fn template_fingerprint(settings: &Settings, integration_registry: &IntegrationRegistry) -> String { + template_fingerprint_with_build_digest(settings, integration_registry, TEMPLATE_BUILD_DIGEST) } -fn template_fingerprint_with_build_digest(settings: &Settings, build_digest: &str) -> String { +fn template_fingerprint_with_build_digest( + settings: &Settings, + integration_registry: &IntegrationRegistry, + build_digest: &str, +) -> String { let mut hasher = sha2::Sha256::new(); hasher.update(build_digest.as_bytes()); - hasher.update( - trusted_server_js::concatenated_hash(trusted_server_js::all_module_ids()).as_bytes(), - ); + hasher + .update(crate::tsjs_bundle::compose_hash(&integration_registry.js_parts_all()).as_bytes()); // EdgeZero's canonical form sorts object keys itself, so independently deserialized // HashMaps hash identically even when a dependency enables `serde_json/preserve_order`. // Array order is preserved: set-valued settings must serialize deterministically @@ -2117,21 +2153,28 @@ fn response_carries_a_seam_marker(was_authorized: bool, settings: &Settings) -> /// calls `scheduleInitialAdInit`, which schedules `adInit` for precisely the traffic /// that opted out. Absent is not the same as empty here. /// +/// It is never nothing at all, because the permission state has to reach the +/// page whether or not the ad stack ran, and a shared template's head cannot carry it. +/// The no-ad-stack answer is [`build_permissions_seam_script`], which sets the state and +/// schedules no ad init. +/// /// Shared by the miss path and by **both** hit finalizers. They previously each spelled /// the decision out, and the two hit paths spelled it `unwrap_or("[]")` — so the gate /// held on a cache miss and was ignored on every cache hit. fn seam_script_for(params: &OwnedProcessResponseParams) -> String { - params - .seam_ad_slots - .as_deref() - .map(|slots| params.ad_bids_state.build_seam_script(slots)) - .unwrap_or_default() + match params.seam_ad_slots.as_deref() { + Some(slots) => params + .ad_bids_state + .build_seam_script(slots, ¶ms.permissions_json), + None => build_permissions_seam_script(¶ms.permissions_json), + } } /// Builds the injection state a cached template needs on the way out. /// -/// The template carries no auction state — that is what makes it shareable — so the -/// per-reader parts are attached here, from this request. +/// The template carries no auction state and no permission state, which is what makes +/// it shareable, so the per-reader parts are attached here, from this request. Both +/// leave through the seam, never through the cached head. fn build_template_assembly_params( entry: &crate::platform::TemplateEntry, settings: &Settings, @@ -2139,6 +2182,7 @@ fn build_template_assembly_params( request_scheme: &str, price_granularity: PriceGranularity, ad_bids_state: AdBidsState, + permissions_json: String, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, @@ -2153,14 +2197,16 @@ fn build_template_assembly_params( request_scheme: request_scheme.to_string(), content_type: entry.metadata.content_type.clone(), // The template already carries the head seam; re-injecting would duplicate it. + permissions_json, ad_slots_script: None, ad_bids_state, auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + // A request that carries any keeps to the origin path, so none reaches a + // document served from a template. + request_state: IntegrationRequestState::default(), } } @@ -2761,6 +2807,13 @@ fn is_html_document_request(req: &Request) -> bool { is_navigation_request(req) } +/// Whether an integration marked this request's response as personalized. +fn request_requires_personalized_delivery(req: &Request) -> bool { + req.extensions() + .get::() + .is_some() +} + /// Removes request headers that can produce a bodyless or partial origin response. fn strip_conditional_and_range_headers(req: &mut Request) { req.headers_mut().remove(header::IF_NONE_MATCH); @@ -2784,14 +2837,14 @@ fn response_carries_body(method: &Method, status: StatusCode) -> bool { && status != StatusCode::NOT_MODIFIED } -/// Prevent shared caches from replaying tag-suppressed HTML to other clients. -fn apply_datadome_client_tag_cache_privacy( +/// Prevent shared caches from replaying personalized HTML to other clients. +fn apply_personalized_response_cache_privacy( response: &mut Response, method: &Method, - suppress_datadome_client_side_tag: bool, + response_is_personalized: bool, content_type: &str, ) { - if suppress_datadome_client_side_tag + if response_is_personalized && response_carries_body(method, response.status()) && is_html_content_type(content_type) { @@ -2904,6 +2957,7 @@ pub fn stream_publisher_body( settings: &Settings, integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { + let permissions_script = permissions_script_for(params, settings); let borrowed = ProcessResponseParams { content_encoding: ¶ms.content_encoding, origin_host: ¶ms.origin_host, @@ -2913,10 +2967,10 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + permissions_script: permissions_script.as_deref(), ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: params.ad_bids_state.script_cell(), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.as_ref(), + request_state: ¶ms.request_state, shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.as_ref(), }; @@ -3018,10 +3072,10 @@ pub async fn stream_publisher_body_async( request_scheme: ¶ms.request_scheme, settings, integration_registry, + permissions_script: permissions_script_for(params, settings), ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.clone(), + request_state: params.request_state.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), deferred_inline_marker: inline_seam_token @@ -3070,14 +3124,14 @@ pub async fn stream_publisher_body_async( .await } -/// Builds the canonical mediator placeholder [`Request`] passed to the collect +/// Builds the canonical ad server placeholder [`Request`] passed to the collect /// phase via [`make_collect_context`]. /// /// The URI is the compile-time constant /// [`MEDIATOR_PLACEHOLDER_URL`](crate::auction::types::MEDIATOR_PLACEHOLDER_URL), /// so the builder is infallible; a default-URI fallback would trip /// [`make_collect_context`]'s `debug_assert_eq!`. -fn mediator_placeholder_request() -> Request { +fn adserver_placeholder_request() -> Request { Request::builder() .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) .body(EdgeBody::empty()) @@ -3150,7 +3204,7 @@ fn rewrite_origin_request( /// See [`AuctionContext::request`]: the orchestrator's collect path runs /// after `send_async` has already consumed the real client request, so this /// context carries a synthetic placeholder. The orchestrator itself -/// instantiates a fresh placeholder when it actually invokes a mediator — +/// instantiates a fresh placeholder when it actually invokes an ad server, /// this argument is plumbing for the (presently unused) case where the /// orchestrator needs the caller's request shape. fn make_collect_context<'a>( @@ -3183,7 +3237,11 @@ pub(crate) const BOT_USER_AGENT_FRAGMENTS: &[&str] = /// Returns true when the request's User-Agent matches any well-known crawler /// fragment in [`BOT_USER_AGENT_FRAGMENTS`]. -pub(crate) fn is_bot_user_agent(req: &Request) -> bool { +/// +/// Public so a module outside this crate treats a crawler the way the page +/// path does. +#[must_use] +pub fn is_bot_user_agent(req: &Request) -> bool { let ua = req .headers() .get("user-agent") @@ -3196,7 +3254,11 @@ pub(crate) fn is_bot_user_agent(req: &Request) -> bool { /// Returns true when the request advertises itself as a prefetch via either /// the standard `Sec-Purpose` or the legacy `Purpose` header. -pub(crate) fn is_prefetch_request(req: &Request) -> bool { +/// +/// Public so a module outside this crate treats a prefetch the way the page +/// path does. +#[must_use] +pub fn is_prefetch_request(req: &Request) -> bool { let header = |name: &str| { req.headers() .get(name) @@ -3295,8 +3357,8 @@ impl AdBidsState { } /// Build the shared-template seam, retaining the same debug prefix as inline. - fn build_seam_script(&self, slots_json: &str) -> String { - let seam = build_seam_script(slots_json, &self.bids()); + fn build_seam_script(&self, slots_json: &str, permissions_json: &str) -> String { + let seam = build_seam_script(slots_json, &self.bids(), permissions_json); let prefix = self .debug_prefix .lock() @@ -3374,11 +3436,10 @@ pub(crate) fn write_bids_to_state( /// enabled cannot bloat every page render without bound. const MAX_AUCTION_DEBUG_DUMP_BYTES: usize = 256 * 1024; -/// Per-bid creative preview length (in bytes) in the `ts-debug` dump. Mirrors -/// the 512-byte upstream-body preview the prebid provider logs on an HTTP error -/// (`integrations/prebid.rs`): enough to identify a creative without copying -/// megabytes of `adm` markup into every page render. The full creative still -/// renders via the injected bids `", + html_escape_for_script(&permissions_json_or_empty(permissions_json)), html_escape_for_script(slots_json), html_escape_for_script(&bids) ) } +/// Build the `` seam script for a request whose ad stack did not run. +/// +/// The head of a shared template carries nothing request-scoped, so the seam is +/// the only place this reader's permission state can be delivered. Before this +/// existed the seam was empty whenever the ad stack was skipped, which left a +/// bot-classified or permission-denied visitor with no state on the page at all. +/// +/// Carries the state and nothing else. It deliberately does not set `adSlots` or +/// `bids` and does not call `scheduleInitialAdInit`, because scheduling `adInit` +/// for traffic that opted out is what the gate in [`seam_script_for`] exists to +/// prevent. +pub(crate) fn build_permissions_seam_script(permissions_json: &str) -> String { + format!( + "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)) + ) +} + /// The slot definitions a shared-mode seam must carry, as JSON. /// /// Mirrors [`template_ad_slots_script`]'s gating: same `should_run_ad_stack` condition, @@ -6153,8 +6280,8 @@ pub(crate) enum TemplateCacheBypassReason { /// concern rather than a hypothetical one. #[display("request carried Authorization")] AuthorizedRequest, - /// Not a 200. This is also what covers a `DataDome` block, which replaces the - /// document with a `403` (`integrations/datadome/protection.rs:778`). + /// Not a 200. This is also what covers a bot protection module's block, which + /// replaces the document with a `403`. #[display("status was not 200 OK")] NonOkStatus, /// Not HTML, so there is no template to transform. @@ -6708,6 +6835,61 @@ pub(crate) fn template_ad_slots_script( } } +/// The permission-state `", + escaped + ) +} + +/// The permission state as page JSON, substituting the empty state for an unset +/// value. +/// +/// [`PermissionState::page_json`] never returns an empty string, so this only +/// covers a params value nothing filled in. `JSON.parse("")` throws, and a +/// thrown head script takes the rest of the snippet with it, so an unset value +/// renders as the empty state rather than as broken JavaScript. +fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { + if permissions_json.is_empty() { + Cow::Owned(PermissionState::default().page_json()) + } else { + Cow::Borrowed(permissions_json) + } +} + /// Build the `tsjs.adSlots` `"# .to_string(), @@ -20281,8 +21148,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"hello"), @@ -20318,8 +21184,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_auction_hold_decodes_multi_member_gzip_buffered() { + #[tokio::test] + async fn stream_publisher_body_async_auction_hold_decodes_multi_member_gzip_buffered() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20330,7 +21196,9 @@ mod tests { ), ) .expect("should create integration registry"); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let services = noop_services(); let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { @@ -20344,6 +21212,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + permissions_json: String::new(), ad_slots_script: Some( r#""# .to_string(), @@ -20356,8 +21225,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; // The `` that triggers bid injection lives in the SECOND gzip // member. `flate2::read::GzDecoder` decodes only the first member, so @@ -20396,8 +21264,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_processes_non_html_stream_after_auction_collect() { + #[tokio::test] + async fn stream_publisher_body_async_processes_non_html_stream_after_auction_collect() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20408,7 +21276,9 @@ mod tests { ), ) .expect("should create integration registry"); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, @@ -20421,6 +21291,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), auction_observation: None, @@ -20430,8 +21301,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( b"body{background:url('https://origin.example.com/asset.png')}", @@ -20474,7 +21344,9 @@ mod tests { ) .expect("should create integration registry"), ); - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let orchestrator = Arc::new(AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + )); let services = noop_services(); let response = Response::builder() .status(StatusCode::OK) @@ -20492,14 +21364,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let publisher_response = PublisherResponse::Stream { response, @@ -20606,7 +21478,9 @@ mod tests { ) .expect("should create integration registry"), ); - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let orchestrator = Arc::new(AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + )); let services = noop_services(); let response = Response::builder() .status(StatusCode::OK) @@ -20645,6 +21519,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + permissions_json: String::new(), ad_slots_script: Some( r#""# .to_string(), @@ -20654,24 +21529,24 @@ mod tests { auction_request: dispatched_auction.as_ref().map(|_| test_auction_request()), dispatched_auction, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), } } #[test] - fn streaming_finalize_emits_gam_attribution_head_before_origin_eof() { + fn streaming_finalize_emits_an_integration_s_head_before_origin_eof() { + use crate::integrations::registry_test_support::tag_fixture as tag; + let mut settings = create_test_settings(); settings - .integrations - .insert_config( - "gpt", + .insert_module_config( + "testing", + tag::MODULE, &serde_json::json!({ - "enabled": true, - "gam_attribution_enabled": true + "mark_bundle": true }), ) - .expect("should insert GPT config"); + .expect("should insert the stand-in's settings"); let body = streaming_finalize_response_with_settings( html_stream_params("", None), @@ -20684,12 +21559,12 @@ mod tests { .expect("should emit UTF-8 HTML"); assert!( - html.contains("__tsjs_gam_attribution_enabled=true"), - "first rewritten head chunk should carry the primary activation flag: {html}" + html.contains(tag::HEAD_FLAG), + "first rewritten head chunk should carry the integration's head insert: {html}" ); assert!( - html.contains("data-ts-gam-attribution=\"true\""), - "first rewritten head chunk should authorize the bundle fallback: {html}" + html.contains(&format!("{}=\"true\"", tag::BUNDLE_ATTRIBUTE)), + "first rewritten head chunk should carry the bundle tag's attribute: {html}" ); } @@ -20737,18 +21612,12 @@ mod tests { fn streaming_finalize_auction_hold_emits_prefix_before_origin_eof() { // A body-close literal in script data must not stop streaming. Only the // request token emitted by lol_html at the structural end is a seam. - let page = br#"
still streaming
"#; + let page = br#"
still streaming
"#; let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "nextjs", - &serde_json::json!({ - "enabled": true, - "rewrite_attributes": ["href", "link", "url"], - }), - ) - .expect("should enable Next.js"); + settings.select_module( + "testing", + crate::integrations::registry_test_support::payload_fixture::MODULE, + ); let params = html_stream_params( "", Some(DispatchedAuction::empty_for_test( @@ -20766,11 +21635,11 @@ mod tests { let html = String::from_utf8(first.to_vec()).expect("should be valid UTF-8"); assert!( html.contains("") && html.contains("still streaming"), - "RSC script data and later article bytes must stream before EOF. Got: {html}" + "script data and later article bytes must stream before EOF. Got: {html}" ); assert!( html.contains("proxy.example.com/app") && !html.contains("origin.example.com/app"), - "Next.js rewriting must complete before the parser seam: {html}" + "the stand-in's rewriting must complete before the parser seam: {html}" ); assert!( html.contains(".adSlots=JSON.parse"), @@ -20871,7 +21740,9 @@ mod tests { #[async_trait::async_trait(?Send)] impl AuctionProvider for GatedAuctionProvider { fn provider_name(&self) -> &'static str { - "seam-test" + // A demand name is snake_case, and it has to match the + // `[demand]` entry the test selects for the auction to route to it. + "seam_test" } async fn request_bids( @@ -20914,24 +21785,16 @@ mod tests { } #[test] - fn parser_confirmed_auction_seam_streams_nextjs_for_every_encoding() { + fn parser_confirmed_auction_seam_streams_a_two_pass_integration_for_every_encoding() { for encoding in ["", "gzip", "deflate", "br"] { let mut settings = create_test_settings(); settings.auction.enabled = true; - settings.auction.providers = - crate::auction::AuctionConfig::legacy_provider_map(&["seam-test"]); + settings.auction.provider_names = vec!["seam_test".to_owned()]; settings.auction.timeout_ms = 60_000; - settings.auction.mediator = None; - settings - .integrations - .insert_config( - "nextjs", - &serde_json::json!({ - "enabled": true, - "rewrite_attributes": ["href", "link", "url"], - }), - ) - .expect("should enable Next.js"); + settings.select_module( + "testing", + crate::integrations::registry_test_support::payload_fixture::MODULE, + ); let client = Arc::new(GatedAuctionHttpClient { inner: StubHttpClient::new(), released: std::sync::atomic::AtomicBool::new(false), @@ -20958,15 +21821,10 @@ mod tests { else { panic!("should dispatch a pending auction"); }; - let payload = r#"{"url":"https://origin.example.com/path","text":""}"#; - let split = payload.find("/path").expect("should find payload split"); - let first_payload = format!("1:T{:x},{}", payload.len(), &payload[..split]); - let first_script = - serde_json::to_string(&first_payload).expect("should encode first payload"); - let second_script = - serde_json::to_string(&payload[split..]).expect("should encode second payload"); - let prefix = format!( - "

before RSC

between scripts
still streaming
" + // One group split across two scripts, so the stand-in holds its + // output from the first script until the second arrives. + let prefix = String::from( + "

before the group

between scripts
still streaming
", ); let page = format!("{prefix}"); let encoded = match encoding { @@ -21029,7 +21887,7 @@ mod tests { ); assert!( prefix.contains("proxy.example.com") && !prefix.contains("origin.example.com"), - "should rewrite the split RSC group for {encoding}: {prefix}" + "should rewrite the split group for {encoding}: {prefix}" ); assert!( !prefix.contains("var b=JSON.parse("), @@ -21077,7 +21935,8 @@ mod tests { "should inject once" ); assert!( - !html.contains("ts-inline-body-close-") && !html.contains("__ts_rsc_"), + !html.contains("ts-inline-body-close-") + && !html.contains(crate::integrations::registry_test_support::payload_fixture::PLACEHOLDER_PREFIX), "should remove internal markers for {encoding}: {html}" ); assert_eq!( @@ -21159,7 +22018,9 @@ mod tests { ) .expect("should create integration registry"), ); - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let orchestrator = Arc::new(AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + )); for (method, status, expected_length, expected_transfer_encoding) in BODILESS_FRAMING_CASES { @@ -21224,7 +22085,9 @@ mod tests { ), ) .expect("should create integration registry"); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let services = noop_services(); for (method, status, expected_length, expected_transfer_encoding) in BODILESS_FRAMING_CASES @@ -21310,7 +22173,9 @@ mod tests { ), ) .expect("should create integration registry"); - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let orchestrator = Arc::new(AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + )); let make_params = || { let ec_context = @@ -21326,6 +22191,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), auction_observation: Some(AuctionObservationContext::from_parts( @@ -21342,8 +22208,7 @@ mod tests { 10, )), price_granularity: PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), } }; let make_stream_response = || PublisherResponse::Stream { @@ -21491,7 +22356,9 @@ mod tests { ) .expect("should create integration registry"), ); - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let orchestrator = Arc::new(AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + )); let services = noop_services(); let response = Response::builder() .status(StatusCode::OK) @@ -21521,6 +22388,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + permissions_json: String::new(), ad_slots_script: Some( r#""# .to_string(), @@ -21533,8 +22401,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let publisher_response = PublisherResponse::Stream { response, @@ -21602,6 +22469,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "Text/HTML; Charset=utf-8".to_string(), + permissions_json: String::new(), ad_slots_script: Some( r#""# .to_string(), @@ -21611,8 +22479,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut output = Vec::new(); @@ -21666,14 +22533,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -21731,18 +22598,12 @@ mod tests { /// routes through `Stream`, and the shared processor pipeline applies it. #[test] fn streaming_html_with_stream_processors_rewrites_body() { - // Configure nextjs so a stream processor is registered. + // Select the stand-in so a stream processor is registered. let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "nextjs", - &serde_json::json!({ - "enabled": true, - "rewrite_attributes": ["href", "link", "url"], - }), - ) - .expect("should update nextjs config"); + settings.select_module( + "testing", + crate::integrations::registry_test_support::payload_fixture::MODULE, + ); let registry = IntegrationRegistry::with_plan( &settings, @@ -21755,7 +22616,7 @@ mod tests { assert!( !registry.html_stream_processor_factories().is_empty(), - "nextjs integration must register an HTML stream processor" + "the stand-in must register an HTML stream processor" ); assert_eq!( classify_response_route( @@ -21784,14 +22645,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -21813,22 +22674,16 @@ mod tests { } /// Document-state survives from the parser pass into the stream processor. - /// `NextJsRscPlaceholderRewriter` writes into `IntegrationDocumentState` - /// during parsing; the request-local stream processor reads it and substitutes. + /// A script rewriter writes into `IntegrationDocumentState` during + /// parsing, and the request-local stream processor reads it and substitutes. /// Regression test: placeholders must be inserted and removed from final output. #[test] fn document_state_placeholders_substitute_through_streaming_path() { let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "nextjs", - &serde_json::json!({ - "enabled": true, - "rewrite_attributes": ["href", "link", "url"], - }), - ) - .expect("should update nextjs config"); + settings.select_module( + "testing", + crate::integrations::registry_test_support::payload_fixture::MODULE, + ); let registry = IntegrationRegistry::with_plan( &settings, Arc::new( @@ -21838,8 +22693,8 @@ mod tests { ) .expect("should create integration registry"); - // Small, single-fragment RSC script — placeholder path (not fallback). - let html = br#""#; + // One small script whose payload is swapped for a placeholder. + let html = br#""#; let params = OwnedProcessResponseParams { csp_nonce_observed: None, template_cache_key: None, @@ -21851,14 +22706,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut output = Vec::new(); @@ -21869,11 +22724,13 @@ mod tests { &settings, ®istry, ) - .expect("should process RSC push"); + .expect("should process the payload script"); let processed = String::from_utf8(output).expect("valid UTF-8"); assert!( - !processed.contains("__ts_rsc_payload_"), + !processed.contains( + crate::integrations::registry_test_support::payload_fixture::PLACEHOLDER_PREFIX + ), "placeholder must be substituted before reaching output. Got: {processed}" ); assert!( @@ -21889,11 +22746,11 @@ mod tests { #[cfg(test)] mod creative_opportunities_tests { use super::super::{ - AdBidsState, MatchedSlotsContext, build_ad_slots_script, build_auction_request, - build_bid_map, build_bids_script, diagnostics_auction_id, html_escape_for_script, + AdBidsState, MatchedSlotsContext, auction_token, build_ad_slots_script, + build_auction_request, build_bid_map, build_bids_script, html_escape_for_script, write_bids_to_state, }; - use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderer, MediaType}; + use crate::auction::types::{Bid, BidRenderer, MediaType}; use crate::consent::ConsentContext; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, @@ -22159,28 +23016,28 @@ mod tests { } /// Guards the browser-visible token every auction path shares: it must - /// be fresh per auction and absent unless diagnostics can consume it. + /// be fresh per auction and absent unless a module reads it. #[test] - fn diagnostics_auction_id_is_fresh_and_gated() { - let mut settings = test_settings(); + fn auction_token_is_fresh_and_made_only_where_a_module_reads_it() { + let settings = test_settings(); + let orchestrator = crate::auction::orchestrator::AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); assert_eq!( - diagnostics_auction_id(&settings), + auction_token(&orchestrator), None, - "no token should be minted without the diagnostics integration" + "no token should be minted where no module reads one" ); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); + let orchestrator = orchestrator.publishing_auction_token(); let first = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); + auction_token(&orchestrator).expect("should mint a token where a module reads one"); let second = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); + auction_token(&orchestrator).expect("should mint a token where a module reads one"); assert!( first.starts_with("ts-auc-"), - "token should use the diagnostics prefix, got `{first}`" + "token should use the auction token prefix, got `{first}`" ); assert_ne!(first, second, "each auction should mint its own token"); } @@ -23063,38 +23920,40 @@ mod tests { } #[test] - fn bid_map_exposes_aps_renderer_and_selected_bid_id() { + fn bid_map_exposes_a_typed_renderer_and_the_bid_it_picks() { // Sanitization is opt-in, so enable it: the script-only creative // below is what drives this bid onto the renderer path. Left at the // default it would survive processing as an ordinary creative. let mut settings = test_settings(); settings.auction.sanitize_creatives = true; - let mut bid = make_bid("atf_sidebar_ad", 1.50, "aps", "fallback-ad", "", ""); + let mut bid = make_bid("atf_sidebar_ad", 1.50, "example", "fallback-ad", "", ""); bid.bid_id = Some("selected-bid".to_string()); bid.creative = Some("".to_string()); bid.nurl = None; bid.burl = None; - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "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, - })); + bid.renderer = Some( + BidRenderer::new( + "example", + serde_json::json!({ + "version": 1, + "bidId": "selected-bid", + "creativeUrl": "https://creative.example/render", + "envelope": "fictional-base64", + }), + ) + .expect("should build the example renderer descriptor") + .picking_bid_by("bidId"), + ); let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); let obj = map["atf_sidebar_ad"] .as_object() - .expect("should include APS bid"); + .expect("should include the renderer bid"); - assert_eq!(obj["hb_bidder"], "aps"); + assert_eq!(obj["hb_bidder"], "example"); assert_eq!(obj["hb_adid"], "selected-bid"); - assert_eq!(obj["renderer"]["type"], "aps"); + assert_eq!(obj["renderer"]["type"], "example"); assert_eq!(obj["renderer"]["bidId"], "selected-bid"); assert!(obj.get("adm").is_none()); @@ -23103,6 +23962,67 @@ mod tests { assert!(script.contains("\\u003C/script\\u003E")); } + #[test] + fn bid_map_prefers_the_renderer_bid_id_over_ad_id_and_the_openrtb_bid_id() { + // Every hb_adid source carries a different value, so the assertion + // below passes only when the renderer field is the one read. The + // envelope is oversized on purpose: reading this field must not + // copy it. + let mut settings = test_settings(); + settings.auction.sanitize_creatives = true; + let mut bid = make_bid("atf_sidebar_ad", 1.50, "example", "ad-id-value", "", ""); + bid.bid_id = Some("openrtb-bid-id".to_string()); + bid.cache_id = None; + bid.creative = Some("".to_string()); + bid.renderer = Some( + BidRenderer::new( + "example", + serde_json::json!({ + "version": 1, + "bidId": "renderer-bid-id", + "creativeUrl": "https://creative.example/render", + "envelope": "A".repeat(200 * 1024), + }), + ) + .expect("should build the example renderer descriptor") + .picking_bid_by("bidId"), + ); + let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); + let obj = map["atf_sidebar_ad"] + .as_object() + .expect("should include the renderer bid"); + + assert_eq!( + obj["hb_adid"], "renderer-bid-id", + "should read hb_adid from the renderer payload rather than ad_id or bid_id" + ); + } + + #[test] + fn bid_map_ignores_a_renderer_bid_id_its_builder_did_not_state() { + let mut settings = test_settings(); + settings.auction.sanitize_creatives = true; + let mut bid = make_bid("atf_sidebar_ad", 1.50, "example", "ad-id-value", "", ""); + bid.cache_id = None; + bid.renderer = Some( + BidRenderer::new("example", serde_json::json!({ "bidId": "renderer-bid-id" })) + .expect("should build renderer descriptor"), + ); + let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); + let obj = map["atf_sidebar_ad"] + .as_object() + .expect("should include the bid"); + + assert_eq!( + obj["hb_adid"], "ad-id-value", + "should ignore a bidId the code that built the renderer did not state it picks its bid by" + ); + } + #[test] fn bid_map_omits_creative_rejected_by_processing_without_renderer() { // Sanitization is opt-in, so enable it: script-only markup is what @@ -23595,8 +24515,8 @@ mod tests { fn settings_with_co() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\n\n[creative_opportunities]\ngam_network_id = \"12345\"\n", - crate_test_settings_str() + "{}\n[creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\n") ); Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } @@ -23604,23 +24524,26 @@ mod tests { /// Settings for a deployment that has no `[creative_opportunities]` /// section, so page-bids answers `404`. fn settings_without_co() -> Settings { - let toml = format!("{}\n[auction]\nenabled = true\n", crate_test_settings_str()); + let toml = format!( + "{}\n", + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\n") + ); Settings::from_toml(&toml) .expect("should parse settings without creative_opportunities") } fn settings_with_co_auction_disabled() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = false\n\n[creative_opportunities]\ngam_network_id = \"12345\"\n", - crate_test_settings_str() + "{}\n[creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = false\n") ); Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } fn settings_with_co_templates_disabled() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", - crate_test_settings_str() + "{}\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\n") ); Settings::from_toml(&toml).expect("should parse settings with disabled templates") } @@ -23691,7 +24614,9 @@ mod tests { #[tokio::test] async fn page_bids_format_absent_or_json_returns_json() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); for path_and_format in ["/2024/article", "/2024/article&format=json"] { let response = run_page_bids_response( &settings, @@ -23723,7 +24648,9 @@ mod tests { #[tokio::test] async fn page_bids_format_rejects_removed_unknown_and_empty_values() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); for format in ["fragment", "scrpit", ""] { let response = run_page_bids_response( &settings, @@ -23802,23 +24729,34 @@ mod tests { captured_request: Arc>>, winning_bid: bool, ) -> AuctionOrchestrator { - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(settings), + ); orchestrator.register_provider(Arc::new(AuctionIdTestProvider { captured_request, winning_bid, })); + // The plan compiled for a deployment publishes a token when a + // selected module reads one, and the stand-in is such a module. + if settings + .selects_module(crate::integrations::registry_test_support::request_fixture::MODULE) + { + orchestrator = orchestrator.publishing_auction_token(); + } orchestrator } #[tokio::test] async fn page_bids_response_includes_auction_id_only_for_winning_bids() { let mut settings = settings_with_co(); - settings.auction.providers = - crate::auction::AuctionConfig::legacy_provider_map(&[AUCTION_ID_TEST_PROVIDER]); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); + settings.demand = crate::auction::test_support::demand_named( + crate::auction::test_support::plain_fixture::MODULE, + &[AUCTION_ID_TEST_PROVIDER], + ); + settings.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); let slots = article_slot(); let winning_stub = Arc::new(StubHttpClient::new()); winning_stub.push_response(200, b"winner".to_vec()); @@ -23873,7 +24811,7 @@ mod tests { .to_string(); assert!( winning_auction_id.starts_with("ts-auc-"), - "page-bids should expose a freshly minted diagnostics token, got `{winning_auction_id}`" + "page-bids should expose a freshly minted auction token, got `{winning_auction_id}`" ); assert_ne!( winning_auction_id, auction_request.id, @@ -23923,10 +24861,10 @@ mod tests { } /// The browser-visible auction ID is minted per auction and only for - /// deployments that run the diagnostics integration, so it can neither + /// deployments that run a module that reads it, so it can neither /// carry EC identity across auctions nor reach pages that ignore it. #[tokio::test] - async fn page_bids_auction_id_is_per_auction_and_gated_on_diagnostics() { + async fn page_bids_auction_id_is_per_auction_and_made_only_where_a_module_reads_it() { async fn winning_auction_id(settings: &Settings) -> Option { let slots = article_slot(); let stub = Arc::new(StubHttpClient::new()); @@ -23970,32 +24908,34 @@ mod tests { } let mut settings = settings_with_co(); - settings.auction.providers = - crate::auction::AuctionConfig::legacy_provider_map(&[AUCTION_ID_TEST_PROVIDER]); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); + settings.demand = crate::auction::test_support::demand_named( + crate::auction::test_support::plain_fixture::MODULE, + &[AUCTION_ID_TEST_PROVIDER], + ); + settings.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); let first = winning_auction_id(&settings) .await - .expect("first auction should expose a diagnostics token"); + .expect("first auction should expose an auction token"); let second = winning_auction_id(&settings) .await - .expect("second auction should expose a diagnostics token"); + .expect("second auction should expose an auction token"); assert_ne!( first, second, "each auction for the same visitor should mint its own token" ); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": false })) - .expect("should disable diagnostics"); + settings.remove_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); assert_eq!( winning_auction_id(&settings).await, None, - "no auction metadata should reach the page without the diagnostics integration" + "no auction metadata should reach the page where no module reads it" ); } @@ -24010,7 +24950,9 @@ mod tests { #[tokio::test] async fn deprecated_alias_response_matches_canonical_path() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let canonical = run_page_bids_response( &settings, @@ -24047,7 +24989,9 @@ mod tests { #[tokio::test] async fn deprecated_alias_response_is_marked_deprecated() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let canonical = run_page_bids_response( &settings, @@ -24091,7 +25035,9 @@ mod tests { settings.creative_opportunities.is_none(), "test settings should have no creative opportunities configured" ); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let response = run_page_bids_response( &settings, @@ -24118,7 +25064,9 @@ mod tests { #[tokio::test] async fn cross_site_request_is_denied_before_configuration_is_revealed() { let settings = settings_without_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let mut req = Request::builder() .method(Method::GET) .uri(format!("https://test-publisher.com{PAGE_BIDS_PATH}?path=/")) @@ -24138,7 +25086,9 @@ mod tests { #[tokio::test] async fn cross_site_fetch_metadata_is_rejected() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let mut req = make_page_bids_request("/2024/01/my-article/"); set_test_header(&mut req, "sec-fetch-site", "cross-site"); @@ -24155,7 +25105,9 @@ mod tests { #[tokio::test] async fn missing_fetch_metadata_without_tsjs_header_is_rejected() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let mut req = make_page_bids_request("/2024/01/my-article/"); req.headers_mut().remove("sec-fetch-site"); @@ -24172,7 +25124,9 @@ mod tests { #[tokio::test] async fn missing_fetch_metadata_with_tsjs_header_is_allowed() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let mut req = make_page_bids_request("/2024/01/my-article/"); req.headers_mut().remove("sec-fetch-site"); set_test_header(&mut req, "x-tsjs-page-bids", "1"); @@ -24190,7 +25144,9 @@ mod tests { #[tokio::test] async fn same_site_fetch_metadata_is_rejected() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let mut req = make_page_bids_request("/2024/01/my-article/"); // `same-site` admits sibling origins under the same registrable // domain — not trusted to spend SSP quota. @@ -24211,7 +25167,9 @@ mod tests { // Spec §8 kill-switch: creative-opportunities.toml with zero slots disables // all server-side auction activity and injection. let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let req = make_page_bids_request("/2024/01/my-article/"); let body = run_page_bids(&settings, &orchestrator, &[], req).await; @@ -24240,7 +25198,9 @@ mod tests { // but the server must not burn SSP request quota running a real auction // for them. Same gate the publisher path applies. let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); set_test_header( @@ -24274,7 +25234,9 @@ mod tests { // Navigations triggered by Sec-Purpose=prefetch should not fire real // SSP auctions — the user has not yet visited the page. let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); set_test_header(&mut req, "sec-purpose", "prefetch"); @@ -24302,7 +25264,9 @@ mod tests { #[tokio::test] async fn page_bids_omits_only_over_limit_dynamic_slot() { let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let mut over_limit = article_slot() .into_iter() .next() @@ -24343,7 +25307,9 @@ mod tests { async fn url_not_matching_any_pattern_returns_empty_response() { // Slots exist but request path does not match — no auction, no injection. let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let slots = article_slot(); // slot matches /20** only let req = make_page_bids_request("/about"); // does not match @@ -24405,7 +25371,9 @@ mod tests { // the auction is off. Consent is allowed here so the test isolates // the kill switch. let settings = settings_with_co_auction_disabled(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let slots = article_slot(); let req = make_page_bids_request("/2024/01/my-article/"); @@ -24434,7 +25402,9 @@ mod tests { // The dedicated template switch must suppress publisher/page-bids // delivery without using the global auction switch. let settings = settings_with_co_templates_disabled(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let slots = article_slot(); let req = make_page_bids_request("/2024/01/my-article/"); @@ -24465,7 +25435,9 @@ mod tests { // hook does not create GPT slots client-side — matching the publisher // navigation path's shared `evaluate_ad_stack_gate` gate. let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let slots = article_slot(); let req = make_page_bids_request("/2024/01/my-article/"); @@ -24535,6 +25507,11 @@ mod tests { /// tests drive the real handlers with a divergent edge host and assert on /// the auction request the orchestrator dispatched and on the telemetry rows /// the handler emitted. + /// + /// The same capturing setup also covers how both paths key the identity + /// graph. A module whose canonical form differs from the cookie value has + /// its row read under the canonical key, so the partner ID stored there + /// reaches the dispatched auction request. mod navigation_publisher_domain_tests { use super::*; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; @@ -24542,6 +25519,7 @@ mod tests { use crate::auction::types::AuctionRequest; use crate::auction::{AuctionContext, AuctionOrchestrator}; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingModule}; use crate::platform::test_support::{ NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, }; @@ -24556,7 +25534,7 @@ mod tests { /// `[publisher] domain` from [`crate_test_settings_str`]. const CONFIGURED_DOMAIN: &str = "test-publisher.com"; - const CAPTURING_PROVIDER: &str = "request-capturing-provider"; + const CAPTURING_PROVIDER: &str = "request_capturing_provider"; /// Records the [`AuctionRequest`] the orchestrator dispatched, then /// fails its launch so no real transport handle is needed. @@ -24625,11 +25603,11 @@ mod tests { fn settings_with_capturing_provider() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\n\n[auction.providers.{CAPTURING_PROVIDER}]\nprotocol = \"openrtb-2.6\"\nendpoint = \"https://capture.example/openrtb2/auction\"\nrouting = \"all_eligible\"\n\n\ + "{}\n[demand]\nmodules = [\"{CAPTURING_PROVIDER}\"]\n\n[demand.{CAPTURING_PROVIDER}]\nimplementation = \"auction.plain-fixture\"\nendpoint = \"https://capture.example/openrtb2/auction\"\nrouting = \"all_eligible\"\n\n\ [creative_opportunities]\ngam_network_id = \"12345\"\n", - crate_test_settings_str() + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\n") ); - Settings::from_toml(&toml).expect("should parse settings with a capturing provider") + settings_from_toml(&toml) } fn article_slot() -> Vec { @@ -24713,7 +25691,9 @@ mod tests { settings: &Settings, captured: &Arc>>, ) -> AuctionOrchestrator { - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(settings), + ); orchestrator.register_provider(Arc::new(RequestCapturingProvider { captured: Arc::clone(captured), })); @@ -24798,8 +25778,12 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); + let registry = test_registry(&settings); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -24882,8 +25866,12 @@ mod tests { .expect("should build test request"); let slots = slots_with_over_limit_dynamic_sibling(); + let registry = test_registry(&settings); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -24944,5 +25932,284 @@ mod tests { assert_only_renderable_slot_was_auctioned(&captured); } + + /// The bidstream partner whose stored ID the canonical row carries. + const CANONICAL_ROW_PARTNER: &str = "ssp.example.com"; + + /// The partner ID the canonical row stores for [`CANONICAL_ROW_PARTNER`]. + const CANONICAL_ROW_UID: &str = "partner-uid-123"; + + /// An identity graph holding one live row under [`CANONICAL_KV_KEY`], + /// and a registry that forwards that row's partner ID as an EID. + fn canonical_row_graph_and_registry() -> (KvIdentityGraph, PartnerRegistry) { + let graph = KvIdentityGraph::in_memory("navigation-canonical-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + CANONICAL_ROW_PARTNER, + CANONICAL_ROW_UID, + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let registry = PartnerRegistry::from_config(&[crate::settings::EcPartner { + name: "Canonical row partner".to_owned(), + source_domain: CANONICAL_ROW_PARTNER.to_owned(), + openrtb_atype: crate::settings::EcPartner::default_openrtb_atype(), + bidstream_enabled: true, + api_token: Some(crate::redacted::Redacted::new( + "canonical-row-partner-token-32-bytes".to_owned(), + )), + batch_rate_limit: crate::settings::EcPartner::default_batch_rate_limit(), + pull_sync_enabled: false, + pull_sync_url: None, + pull_sync_allowed_domains: vec![], + pull_sync_ttl_sec: crate::settings::EcPartner::default_pull_sync_ttl_sec(), + pull_sync_rate_limit: crate::settings::EcPartner::default_pull_sync_rate_limit(), + ts_pull_token: None, + }]) + .expect("should build a registry with one bidstream partner"); + (graph, registry) + } + + /// A returning visitor carrying the identifier [`CanonicalizingModule`] + /// creates, with consent that permits the server-side auction. + fn canonicalizing_returning_visitor() -> EcContext { + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + EcContext::new_for_test(Some(CANONICAL_COOKIE_VALUE.to_owned()), consent) + .with_module_for_test(Arc::new(CanonicalizingModule)) + } + + /// Asserts the dispatched auction request carried the partner ID the + /// canonical row stores, and the request snapshot is bound to the + /// canonical key. + fn assert_auction_used_the_canonical_row( + captured: &Arc>>, + ec_context: &EcContext, + ) { + let request = captured + .lock() + .expect("should lock captured request") + .clone() + .expect("should dispatch an auction request"); + let eids = request + .user + .eids + .expect("the auction should carry server-side EIDs from the identity graph"); + assert!( + eids.iter().any(|eid| eid.source == CANONICAL_ROW_PARTNER + && eid.uids.iter().any(|uid| uid.id == CANONICAL_ROW_UID)), + "the auction should carry the partner ID stored under the canonical key, got {eids:?}" + ); + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the request snapshot should be bound to the canonical key" + ); + } + + #[tokio::test] + async fn initial_navigation_reads_the_identity_row_under_the_canonical_key() { + // The identity graph stores a row under the owning module's + // canonical form of the identifier. Preloaded and resolved under + // the identifier as issued, a module whose canonical form differs + // from the cookie value found no row, so the auction carried no + // server-side EIDs. + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + Arc::new(RecordingTelemetrySink::default()), + ); + let (graph, registry) = canonical_row_graph_and_registry(); + let mut ec_context = canonicalizing_returning_visitor(); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) + .header(header::HOST, EDGE_HOST) + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); + + let _ = handle_publisher_request( + AppContext { + settings: &settings, + integration_registry: &test_registry(&settings), + }, + &services, + Some(&graph), + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: Some(®istry), + }, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy publisher request"); + + assert_auction_used_the_canonical_row(&captured, &ec_context); + } + + #[tokio::test] + async fn initial_navigation_keeps_a_new_identifiers_cookie() { + // Generation binds the request snapshot to the canonical key. The + // navigation preload that follows read under the identifier as + // issued, found nothing there, and replaced that snapshot with a + // miss, so EC finalization skipped the cookie for the identifier + // this request had just created. The second store's first point + // read misses the row generation just wrote, as an eventually + // consistent store can right after a write. The preload keeps + // generation's snapshot in that case only when it compares both + // snapshots under the canonical key. + let stores = [ + ( + "a consistent store", + KvIdentityGraph::in_memory("navigation-new-identifier-store"), + ), + ( + "a store whose first point read misses", + KvIdentityGraph::stale_lookup("navigation-stale-read-store", 1), + ), + ]; + for (store, graph) in stores { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + Arc::new(RecordingTelemetrySink::default()), + ); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(None, consent) + .with_module_for_test(Arc::new(CanonicalizingModule)); + ec_context + .generate_if_needed( + &settings, + Some(&graph), + &crate::platform::test_support::noop_services(), + ) + .await + .expect("should create the identifier through the module"); + assert_eq!( + ec_context.ec_value(), + Some(CANONICAL_COOKIE_VALUE), + "test precondition: the module should create its identifier" + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) + .header(header::HOST, EDGE_HOST) + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); + + let _ = handle_publisher_request( + AppContext { + settings: &settings, + integration_registry: &test_registry(&settings), + }, + &services, + Some(&graph), + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy publisher request"); + + let mut response = Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + &crate::platform::test_support::noop_services(), + ) + .await; + + let cookies: Vec<&str> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("ts-ec=") && !cookie.contains("Max-Age=0")), + "the identifier this request created should reach the browser with {store}, \ + got {cookies:?}" + ); + } + } + + #[tokio::test] + async fn page_bids_reads_the_identity_row_under_the_canonical_key() { + // The same keying for the SPA re-auction endpoint, which loads the + // row itself only once a live auction will run. + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let services = services_with( + Arc::new(crate::platform::test_support::NoopHttpClient), + Arc::new(RecordingTelemetrySink::default()), + ); + let (graph, registry) = canonical_row_graph_and_registry(); + let mut ec_context = canonicalizing_returning_visitor(); + let mut req = HttpRequest::builder() + .method(Method::GET) + .uri(format!( + "https://{EDGE_HOST}/_ts/page-bids?path=/2024/01/my-article/" + )) + .header(header::HOST, EDGE_HOST) + .body(EdgeBody::empty()) + .expect("should build test request"); + req.headers_mut().insert( + header::HeaderName::from_static("sec-fetch-site"), + HeaderValue::from_static("same-origin"), + ); + + let _ = handle_page_bids( + &settings, + &services, + Some(&graph), + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: Some(®istry), + }, + &mut ec_context, + req, + ) + .await + .expect("should return ok response"); + + assert_auction_used_the_canonical_row(&captured, &ec_context); + } } } diff --git a/crates/trusted-server-core/src/redacted.rs b/crates/trusted-server-core/src/redacted.rs index 3389c4c817..df17f43708 100644 --- a/crates/trusted-server-core/src/redacted.rs +++ b/crates/trusted-server-core/src/redacted.rs @@ -1,14 +1,89 @@ -// NOTE: This file is also included in build.rs via #[path]. -// It must remain self-contained (no `crate::` imports). - -//! A wrapper type that redacts sensitive values in [`Debug`] and [`fmt::Display`] output. +//! A wrapper type that redacts sensitive values in [`Debug`] and [`fmt::Display`] output, +//! and the marking that hides sensitive settings from the configuration view. //! //! Use [`Redacted`] for secrets, passwords, API keys, and other sensitive values //! that must never appear in logs or error messages. +//! +//! A settings field holding a value that is sensitive but is not a +//! [`Redacted`] carries `#[serde(serialize_with = "crate::redacted::sensitive")]`. +use core::cell::Cell; use core::fmt; -use serde::{Deserialize, Serialize}; +use serde::ser::SerializeMap as _; +use serde::{Deserialize, Serialize, Serializer}; + +/// The key a sensitive value is wrapped under while [`to_marked_value`] +/// runs. It starts with a control character, so no setting can share it. +pub const SENSITIVE_MARKER: &str = "\u{1}sensitive"; + +std::thread_local! { + static MARKING: Cell = const { Cell::new(false) }; +} + +/// Restores the marking flag when a marked serialization ends, including +/// by a panic. +struct MarkingGuard { + previous: bool, +} + +impl MarkingGuard { + fn begin() -> Self { + Self { + previous: MARKING.with(|marking| marking.replace(true)), + } + } +} + +impl Drop for MarkingGuard { + fn drop(&mut self) { + MARKING.with(|marking| marking.set(self.previous)); + } +} + +/// Serializes `value` to JSON with every sensitive value wrapped as +/// `{SENSITIVE_MARKER: value}`. +/// +/// Only this call marks. Every other serialization, the configuration push +/// included, writes sensitive values as they are. +/// +/// # Errors +/// +/// When `value` cannot be represented as JSON. +pub fn to_marked_value(value: &T) -> Result +where + T: Serialize + ?Sized, +{ + let _guard = MarkingGuard::begin(); + serde_json::to_value(value) +} + +/// Whether a [`to_marked_value`] call is running on this thread. +#[must_use] +pub fn is_marking() -> bool { + MARKING.with(Cell::get) +} + +/// Serializes a sensitive settings field, for +/// `#[serde(serialize_with = "crate::redacted::sensitive")]`, marked as +/// [`to_marked_value`] says. +/// +/// # Errors +/// +/// As the serializer's own. +pub fn sensitive(value: &T, serializer: S) -> Result +where + T: Serialize + ?Sized, + S: Serializer, +{ + if is_marking() { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry(SENSITIVE_MARKER, value)?; + map.end() + } else { + value.serialize(serializer) + } +} /// Wraps a value so that [`Debug`] and [`fmt::Display`] print `[REDACTED]` /// instead of the inner contents. @@ -16,6 +91,8 @@ use serde::{Deserialize, Serialize}; /// Access the real value via [`expose`](Redacted::expose). Callers must /// never log or display the returned reference. /// +/// Serializes as the inner value, marked as [`to_marked_value`] says. +/// /// # Examples /// /// ``` @@ -25,16 +102,21 @@ use serde::{Deserialize, Serialize}; /// assert_eq!(format!("{:?}", secret), "[REDACTED]"); /// assert_eq!(secret.expose(), "my-secret-key"); /// ``` -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Deserialize)] #[serde(transparent)] pub struct Redacted(T); +impl Serialize for Redacted { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + sensitive(&self.0, serializer) + } +} + impl Redacted { /// Creates a new [`Redacted`] value. - #[allow( - dead_code, - reason = "used by the library crate but not by build.rs path inclusion" - )] pub fn new(value: T) -> Self { Self(value) } @@ -143,6 +225,55 @@ mod tests { ); } + #[test] + fn serialization_is_transparent_outside_a_marked_serialization() { + let secret = Redacted::new("serialize-me".to_owned()); + + let value = serde_json::to_value(&secret).expect("should serialize"); + + assert_eq!(value, serde_json::json!("serialize-me")); + assert!(!is_marking(), "should not be marking outside the call"); + } + + #[test] + fn a_marked_serialization_wraps_redacted_and_sensitive_values() { + #[derive(Serialize)] + struct Fixture { + plain: String, + #[serde(serialize_with = "sensitive")] + marked: String, + secret: Redacted, + } + let fixture = Fixture { + plain: "open".to_owned(), + marked: "hidden".to_owned(), + secret: Redacted::new("secret".to_owned()), + }; + + let marked = to_marked_value(&fixture).expect("should serialize"); + let normal = serde_json::to_value(&fixture).expect("should serialize"); + + assert_eq!( + marked, + serde_json::json!({ + "plain": "open", + "marked": { SENSITIVE_MARKER: "hidden" }, + "secret": { SENSITIVE_MARKER: "secret" }, + }), + "should wrap only the marked values" + ); + assert_eq!( + normal, + serde_json::json!({ + "plain": "open", + "marked": "hidden", + "secret": "secret", + }), + "should leave an ordinary serialization unchanged" + ); + assert!(!is_marking(), "should clear the flag when the call ends"); + } + #[test] fn struct_with_redacted_field_debug() { #[derive(Debug)] diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 46ff4bf5b4..d04facc902 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -24,6 +24,17 @@ use crate::settings::Settings; #[derive(Clone, Copy, Debug)] pub struct TerminalPrivateResponse; +/// Request marker meaning the response is personalized to this request and +/// must not be shared through any cache or template. +/// +/// Any integration may set it from a request filter, and core acts on it +/// without knowing which integration asked. Whenever it is present the request +/// keeps to the origin path rather than a shared template. For an HTML +/// document core also buffers the full body, and it enforces private caching +/// on the HTML response that body produces. +#[derive(Debug, Clone, Copy)] +pub struct PersonalizedResponse; + const INACTIVE_AD_STACK_BROWSER_CACHE_CONTROL: &str = "private, max-age=60"; #[derive(Debug, Clone, Copy)] @@ -84,7 +95,10 @@ pub fn enforce_private_no_store(response: &mut Response) { } /// Marks a Trusted Server response as terminal-private and applies its cache policy. -pub(crate) fn enforce_terminal_private_cache_privacy(response: &mut Response) { +/// +/// Public so a response an integration crate synthesizes for one reader is +/// kept out of shared caches the same way. +pub fn enforce_terminal_private_cache_privacy(response: &mut Response) { enforce_private_no_store(response); response.extensions_mut().insert(TerminalPrivateResponse); } @@ -144,6 +158,12 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { .remove::(); } +/// Headers a response must leave with, whatever `settings.response_headers` +/// say. [`apply_response_headers_with_cache_privacy`] sets them again after +/// the operator's headers. +#[derive(Debug, Clone)] +pub struct RequiredHeaders(pub HeaderMap); + /// Applies operator-configured `settings.response_headers` with cookie-privacy /// hardening. /// @@ -188,6 +208,12 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: response.headers_mut().insert(header_name, header_value); } + if let Some(RequiredHeaders(required)) = response.extensions_mut().remove::() { + for (name, value) in &required { + response.headers_mut().insert(name.clone(), value.clone()); + } + } + enforce_uncacheable_cache_privacy(response); // Operator headers can themselves introduce Set-Cookie (alongside public @@ -219,7 +245,13 @@ mod tests { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -610,4 +642,30 @@ mod tests { "operator headers should still apply to cacheable responses" ); } + + #[test] + fn a_required_header_outlasts_an_operator_header_of_the_same_name() { + let settings = settings_with_response_headers(&[ + ("Content-Security-Policy", "default-src 'self'"), + ("X-Frame-Options", "SAMEORIGIN"), + ]); + let mut response = response_builder() + .header( + header::CONTENT_SECURITY_POLICY, + "sandbox; default-src 'none'", + ) + .header(header::X_FRAME_OPTIONS, "DENY") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + let required = response.headers().clone(); + response.extensions_mut().insert(RequiredHeaders(required)); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response.headers()[header::CONTENT_SECURITY_POLICY], + "sandbox; default-src 'none'" + ); + assert_eq!(response.headers()[header::X_FRAME_OPTIONS], "DENY"); + } } diff --git a/crates/trusted-server-core/src/rsc_flight.rs b/crates/trusted-server-core/src/rsc_flight.rs index a9e3a40cec..dac4a4b6ae 100644 --- a/crates/trusted-server-core/src/rsc_flight.rs +++ b/crates/trusted-server-core/src/rsc_flight.rs @@ -29,7 +29,7 @@ enum RowState { /// URLs like `\/\/origin.example.com` (JSON-escaped slashes) will not be rewritten. This is /// acceptable because Flight responses from client-side navigation typically contain plain URLs, /// not doubly-escaped JSON-in-JS content. For inlined `__next_f` data in HTML (which can have -/// escape sequences), the HTML post-processor in `integrations/nextjs/` handles those cases. +/// escape sequences), the HTML post-processor in `crates/framework/nextjs` handles those cases. pub struct RscFlightUrlRewriter { origin_url: String, origin_http_url: Option, diff --git a/crates/trusted-server-core/src/secret_resolution.rs b/crates/trusted-server-core/src/secret_resolution.rs index f362a0b7b2..6bf4afa67f 100644 --- a/crates/trusted-server-core/src/secret_resolution.rs +++ b/crates/trusted-server-core/src/secret_resolution.rs @@ -1,18 +1,91 @@ //! Runtime resolution of `EdgeZero` app-config secret references. //! //! Config blobs carry secret-store key names at rest. This module walks the -//! public `EdgeZero` metadata contract and replaces those names only in the -//! in-memory value used to build runtime [`crate::settings::Settings`]. +//! public `EdgeZero` metadata contract, together with the leaves +//! [`ConfiguredSecretFields`] reads out of the configuration itself, and +//! replaces those names only in the in-memory value used to build runtime +//! [`crate::settings::Settings`]. + +use std::fmt; use edgezero_core::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde_json::Value; use crate::error::TrustedServerError; +use crate::inspect::config::{PathPattern, PathStep}; use crate::platform::{PlatformSecretStore, StoreName}; +/// Where one resolution pass wrote secrets, what it wrote, and every leaf it +/// looked for, which the configuration view masks. +/// +/// Never serialized, and [`fmt::Debug`] prints only how many there are. +#[derive(Clone, Default)] +pub struct ResolvedSecrets { + paths: Vec>, + values: Vec, + patterns: Vec, +} + +impl ResolvedSecrets { + /// The concrete path of every leaf a secret was written into. + pub(crate) fn paths(&self) -> &[Vec] { + &self.paths + } + + /// The secret values written. + pub(crate) fn values(&self) -> &[String] { + &self.values + } + + /// Every secret leaf the pass looked for, found or not. + pub(crate) fn patterns(&self) -> &[PathPattern] { + &self.patterns + } + + /// A record of `paths` and `values`, for a test of what uses one. + #[cfg(test)] + pub(crate) fn recorded(paths: Vec>, values: Vec) -> Self { + Self { + paths, + values, + patterns: Vec::new(), + } + } +} + +impl fmt::Debug for ResolvedSecrets { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedSecrets") + .field("paths", &self.paths.len()) + .field("values", &self.values.len()) + .field("patterns", &self.patterns.len()) + .finish() + } +} + +/// Secret leaves whose paths come from the configuration rather than the type. +/// +/// [`AppConfigMeta::secret_fields`] is an associated function of the type, so +/// every path it can return is fixed, and `EdgeZero`'s path segments have no +/// way to say "whatever name the operator chose". A configuration can hold +/// secrets under such a name, as an Edge Cookie module block under a label +/// does, and an implementation of this trait finds those by reading the +/// configuration being loaded. +pub trait ConfiguredSecretFields: AppConfigMeta { + /// The secret leaves `data` holds under names the operator chose, each + /// with its full path from the configuration root. + fn configured_secret_fields(data: &Value) -> Vec; +} + /// Resolve all secret references in a serialized Trusted Server app config. /// +/// Both the fixed leaves [`AppConfigMeta::secret_fields`] lists and the ones +/// [`ConfiguredSecretFields::configured_secret_fields`] reads out of `data` +/// are resolved in one pass, so a failure in either leaves the configuration +/// as it was. +/// /// The input is mutated in memory; the verified envelope is never rewritten. /// Secret values are not included in structural or platform errors. /// @@ -21,167 +94,220 @@ use crate::platform::{PlatformSecretStore, StoreName}; /// Returns [`TrustedServerError::Configuration`] when a required path or key /// is malformed, a secret is unavailable, is not valid UTF-8, or resolves to an /// empty value. -pub fn resolve_secret_references( +pub fn resolve_secret_references( data: &mut Value, secret_store: &dyn PlatformSecretStore, default_store_name: &StoreName, ) -> Result<(), Report> { + resolve_secret_references_with::(data, secret_store, default_store_name, Vec::new()) + .map(|_| ()) +} + +/// Resolve all secret references in a serialized Trusted Server app config, +/// together with `extra`, the leaves known only where the configuration is +/// loaded, such as the ones a deployment's modules declare in their own +/// tables. +/// +/// A leaf listed more than once is looked up once, because the second lookup +/// would read the secret itself as the name of a key. What comes back says +/// where the secrets went, which the configuration view masks. +/// +/// # Errors +/// +/// As [`resolve_secret_references`]. +pub fn resolve_secret_references_with( + data: &mut Value, + secret_store: &dyn PlatformSecretStore, + default_store_name: &StoreName, + extra: Vec, +) -> Result> { + let fields = listed_fields::(data, extra); let mut resolved_data = data.clone(); - for field in C::secret_fields() { + let mut resolver = Resolver { + secret_store, + default_store_name, + resolved: ResolvedSecrets::default(), + }; + for field in fields { if matches!(field.kind, SecretKind::StoreRef) { continue; } - resolve_field( - &mut resolved_data, - &field, - &field.path, - "", - secret_store, - default_store_name, - )?; + resolver + .resolved + .patterns + .extend(PathPattern::from_segments(&field.path)); + resolver.resolve_field(&mut resolved_data, &field, &field.path, "", &[])?; } *data = resolved_data; - Ok(()) + Ok(resolver.resolved) } -fn resolve_field( - node: &mut Value, - field: &SecretField, - remaining: &[SecretPathSegment], - rendered_path: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, -) -> Result<(), Report> { - match remaining.split_first() { - Some((SecretPathSegment::Field(name), [])) => resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ), - Some((SecretPathSegment::OptionalField(name), [])) => { - if matches!(node.get(name.as_ref()), None | Some(Value::Null)) { - return Ok(()); +/// The secret leaves a resolution pass over `data` with `extra` looks for, +/// with nothing resolved, for a check that runs while the secret fields still +/// hold key names. +pub(crate) fn unresolved_secrets( + data: &Value, + extra: Vec, +) -> ResolvedSecrets { + ResolvedSecrets { + patterns: listed_fields::(data, extra) + .iter() + .filter(|field| !matches!(field.kind, SecretKind::StoreRef)) + .filter_map(|field| PathPattern::from_segments(&field.path)) + .collect(), + ..ResolvedSecrets::default() + } +} + +/// Every secret leaf of `C` and `extra` in `data`, each listed once. +fn listed_fields( + data: &Value, + extra: Vec, +) -> Vec { + let mut listed = std::collections::BTreeSet::new(); + C::secret_fields() + .into_iter() + .chain(C::configured_secret_fields(data)) + .chain(extra) + .filter(|field| listed.insert(field.dotted_path())) + .collect() +} + +/// One resolution pass, recording each secret it writes. +struct Resolver<'a> { + secret_store: &'a dyn PlatformSecretStore, + default_store_name: &'a StoreName, + resolved: ResolvedSecrets, +} + +impl Resolver<'_> { + /// `rendered_path` names the node for messages, and `steps` is the same + /// path as the configuration view addresses it. + fn resolve_field( + &mut self, + node: &mut Value, + field: &SecretField, + remaining: &[SecretPathSegment], + rendered_path: &str, + steps: &[PathStep], + ) -> Result<(), Report> { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), [])) => { + self.resolve_leaf(node, field, name.as_ref(), rendered_path, steps) } - resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::Field(name), rest)) => { - let next_path = join_field(rendered_path, name.as_ref()); - let child = node - .as_object_mut() - .and_then(|object| object.get_mut(name.as_ref())) - .ok_or_else(|| missing_path(&next_path))?; - if child.is_null() { - return Err(missing_path(&next_path)); + Some((SecretPathSegment::OptionalField(name), [])) => { + if matches!(node.get(name.as_ref()), None | Some(Value::Null)) { + return Ok(()); + } + self.resolve_leaf(node, field, name.as_ref(), rendered_path, steps) } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::OptionalField(name), rest)) => { - let next_path = join_field(rendered_path, name.as_ref()); - let Some(child) = node - .as_object_mut() - .and_then(|object| object.get_mut(name.as_ref())) - else { - return Ok(()); - }; - if child.is_null() { - return Ok(()); + Some((SecretPathSegment::Field(name), rest)) => { + let next_path = join_field(rendered_path, name.as_ref()); + let child = node + .as_object_mut() + .and_then(|object| object.get_mut(name.as_ref())) + .ok_or_else(|| missing_path(&next_path))?; + if child.is_null() { + return Err(missing_path(&next_path)); + } + let next_steps = with_step(steps, PathStep::Key(name.to_string())); + self.resolve_field(child, field, rest, &next_path, &next_steps) } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::ArrayEach, rest)) => { - let items = node.as_array_mut().ok_or_else(|| { - configuration_error(format!("expected an array at `{rendered_path}`")) - })?; - for (index, item) in items.iter_mut().enumerate() { - let indexed_path = format!("{rendered_path}[{index}]"); - resolve_field( - item, - field, - rest, - &indexed_path, - secret_store, - default_store_name, - )?; + Some((SecretPathSegment::OptionalField(name), rest)) => { + let next_path = join_field(rendered_path, name.as_ref()); + let Some(child) = node + .as_object_mut() + .and_then(|object| object.get_mut(name.as_ref())) + else { + return Ok(()); + }; + if child.is_null() { + return Ok(()); + } + let next_steps = with_step(steps, PathStep::Key(name.to_string())); + self.resolve_field(child, field, rest, &next_path, &next_steps) } - Ok(()) + Some((SecretPathSegment::ArrayEach, rest)) => { + let items = node.as_array_mut().ok_or_else(|| { + configuration_error(format!("expected an array at `{rendered_path}`")) + })?; + for (index, item) in items.iter_mut().enumerate() { + let indexed_path = format!("{rendered_path}[{index}]"); + let next_steps = with_step(steps, PathStep::Index(index)); + self.resolve_field(item, field, rest, &indexed_path, &next_steps)?; + } + Ok(()) + } + Some(_) => Err(configuration_error(format!( + "unsupported secret path segment in `{}`", + field.dotted_path() + ))), + None => Ok(()), } - Some(_) => Err(configuration_error(format!( - "unsupported secret path segment in `{}`", - field.dotted_path() - ))), - None => Ok(()), } -} -fn resolve_leaf( - parent: &mut Value, - field: &SecretField, - key: &str, - rendered_parent: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, -) -> Result<(), Report> { - let leaf_path = join_field(rendered_parent, key); - let object = parent.as_object_mut().ok_or_else(|| { - configuration_error(format!("expected an object containing `{leaf_path}`")) - })?; - - let key_name = match object.get(key) { - Some(Value::String(value)) if !value.is_empty() => value.clone(), - Some(Value::Null) | None if field.optional => return Ok(()), - Some(Value::Null) | None => return Err(missing_path(&leaf_path)), - Some(Value::String(_)) => { - return Err(configuration_error(format!( - "secret key reference at `{leaf_path}` must not be empty" - ))); - } - _ => { + fn resolve_leaf( + &mut self, + parent: &mut Value, + field: &SecretField, + key: &str, + rendered_parent: &str, + steps: &[PathStep], + ) -> Result<(), Report> { + let leaf_path = join_field(rendered_parent, key); + let object = parent.as_object_mut().ok_or_else(|| { + configuration_error(format!("expected an object containing `{leaf_path}`")) + })?; + + let key_name = match object.get(key) { + Some(Value::String(value)) if !value.is_empty() => value.clone(), + Some(Value::Null) | None if field.optional => return Ok(()), + Some(Value::Null) | None => return Err(missing_path(&leaf_path)), + Some(Value::String(_)) => { + return Err(configuration_error(format!( + "secret key reference at `{leaf_path}` must not be empty" + ))); + } + _ => { + return Err(configuration_error(format!( + "secret key reference at `{leaf_path}` must be a string" + ))); + } + }; + + let resolved = self + .secret_store + .get_string(self.default_store_name, &key_name) + .map_err(|_| { + configuration_error(format!( + "failed to resolve secret reference at `{leaf_path}` from secret store \ + `{}`", + self.default_store_name + )) + })?; + if resolved.is_empty() { return Err(configuration_error(format!( - "secret key reference at `{leaf_path}` must be a string" + "resolved secret at `{leaf_path}` must not be empty" ))); } - }; - let resolved = secret_store - .get_string(default_store_name, &key_name) - .map_err(|_| { - configuration_error(format!( - "failed to resolve secret reference at `{leaf_path}` from secret store \ - `{default_store_name}`" - )) - })?; - if resolved.is_empty() { - return Err(configuration_error(format!( - "resolved secret at `{leaf_path}` must not be empty" - ))); + self.resolved + .paths + .push(with_step(steps, PathStep::Key(key.to_owned()))); + if !self.resolved.values.contains(&resolved) { + self.resolved.values.push(resolved.clone()); + } + object.insert(key.to_owned(), Value::String(resolved)); + Ok(()) } +} - object.insert(key.to_owned(), Value::String(resolved)); - Ok(()) +/// `steps` with `step` added to the end. +fn with_step(steps: &[PathStep], step: PathStep) -> Vec { + let mut next = Vec::with_capacity(steps.len() + 1); + next.extend_from_slice(steps); + next.push(step); + next } fn join_field(prefix: &str, field: &str) -> String { @@ -238,6 +364,28 @@ mod tests { struct Fixture; + impl ConfiguredSecretFields for Fixture { + /// Every key under `labeled` holds its secret at + /// `labeled..secret`, which is the shape a fixed path cannot + /// name. + fn configured_secret_fields(data: &Value) -> Vec { + data.get("labeled") + .and_then(Value::as_object) + .into_iter() + .flatten() + .map(|(name, _)| SecretField { + kind: SecretKind::KeyInDefault, + optional: false, + path: vec![ + SecretPathSegment::Field("labeled".into()), + SecretPathSegment::Field(name.clone().into()), + SecretPathSegment::Field("secret".into()), + ], + }) + .collect() + } + } + impl AppConfigMeta for Fixture { fn secret_fields() -> Vec { vec![ @@ -298,6 +446,61 @@ mod tests { assert!(data["outer"][0]["optional"].is_null()); } + #[test] + fn a_leaf_listed_twice_is_looked_up_once() { + // The second lookup would read the secret itself as the name of a + // key, which this store does not hold. + let mut data = serde_json::json!({ + "outer": [{"token": "token-a"}], + "feature": {"credential": "feature-key"} + }); + let listed_again = vec![SecretField { + kind: SecretKind::KeyInDefault, + optional: true, + path: vec![ + SecretPathSegment::OptionalField("feature".into()), + SecretPathSegment::Field("credential".into()), + ], + }]; + + resolve_secret_references_with::( + &mut data, + &store(), + &StoreName::from("secrets"), + listed_again, + ) + .expect("should look a leaf up once however often it is listed"); + + assert_eq!(data["feature"]["credential"], "resolved-feature"); + } + + #[test] + fn resolves_a_leaf_only_the_caller_lists() { + let mut data = serde_json::json!({ + "outer": [{"token": "token-a"}], + "added": {"key_name": "token-b"} + }); + let added = vec![SecretField { + kind: SecretKind::KeyInDefault, + optional: true, + path: vec![ + SecretPathSegment::OptionalField("added".into()), + SecretPathSegment::Field("key_name".into()), + ], + }]; + + resolve_secret_references_with::( + &mut data, + &store(), + &StoreName::from("secrets"), + added, + ) + .expect("should resolve the leaf the caller listed"); + + assert_eq!(data["added"]["key_name"], "resolved-b"); + assert_eq!(data["outer"][0]["token"], "resolved-a"); + } + #[test] fn resolves_present_and_skips_absent_optional_intermediate() { let mut absent = serde_json::json!({ @@ -316,6 +519,38 @@ mod tests { assert_eq!(present["feature"]["credential"], "resolved-feature"); } + #[test] + fn resolves_a_secret_under_a_name_only_the_configuration_holds() { + let mut data = serde_json::json!({ + "outer": [{"token": "token-a"}], + "labeled": {"chosen": {"secret": "feature-key"}}, + }); + + resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) + .expect("should resolve a secret the fixed paths cannot name"); + + assert_eq!(data["outer"][0]["token"], "resolved-a"); + assert_eq!(data["labeled"]["chosen"]["secret"], "resolved-feature"); + } + + #[test] + fn a_failed_configured_field_leaves_the_fixed_ones_unresolved() { + // Fixed and configured leaves resolve in one pass, so a failure in + // either hands back the configuration exactly as it arrived. + let mut data = serde_json::json!({ + "outer": [{"token": "token-a"}], + "labeled": {"chosen": {"secret": "missing"}}, + }); + let original = data.clone(); + + let err = + resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) + .expect_err("should reject a missing configured secret"); + + assert!(err.to_string().contains("labeled.chosen.secret")); + assert_eq!(data, original, "should preserve unresolved data on failure"); + } + #[test] fn rejects_missing_required_path_without_secret_values() { for mut data in [ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 3efc157701..03df506aa8 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -6,23 +6,28 @@ use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; use sha2::{Digest as _, Sha256}; -use std::collections::{HashMap, HashSet}; +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::sync::OnceLock; use std::time::Duration; use subtle::ConstantTimeEq as _; use url::Url; -use validator::{Validate, ValidationError}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; use crate::auction_config_types::AuctionConfig; use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; +use crate::ec::module::{ + EcModuleSelection, HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_KEY, check_named_module_configuration, +}; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; +use crate::provider_table::{ProviderChoice, ProviderList, SectionModules}; use crate::redacted::Redacted; #[cfg(test)] @@ -39,10 +44,11 @@ pub struct Publisher { /// (see [`ec_cookie_domain`](Self::ec_cookie_domain)). #[validate(custom(function = validate_cookie_domain))] pub cookie_domain: String, + #[serde(serialize_with = "crate::redacted::sensitive")] #[validate(custom(function = validate_no_trailing_slash))] pub origin_url: String, /// Optional outbound Host header to send while connecting to `origin_url`. - #[serde(default)] + #[serde(default, serialize_with = "crate::redacted::sensitive")] #[validate(custom(function = validate_host_header_override))] pub origin_host_header_override: Option, /// Secret used to encrypt/decrypt proxied URLs in `/first-party/proxy`. @@ -215,165 +221,111 @@ impl Publisher { } } -#[derive(Default, Clone, Deserialize, Serialize)] -pub struct IntegrationSettings { - #[serde(flatten)] - entries: HashMap, -} - -impl std::fmt::Debug for IntegrationSettings { +/// Which integrations run, and the settings each one is given. +/// +/// The sections of module types core does not read itself, such as `[cmp]` or +/// `[tag]`, each named for the type of module it selects. +/// +/// Every top-level table that is not one of Trusted Server's own settings is +/// read as one of these. Whether its type is one a module on offer has is +/// only known where the registry is built, so it is checked there. +#[derive(Clone, Default, PartialEq, Serialize)] +#[serde(transparent)] +pub struct TypeSections(BTreeMap); + +impl std::fmt::Debug for TypeSections { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut integration_ids = self.entries.keys().collect::>(); - integration_ids.sort_unstable(); - formatter - .debug_struct("IntegrationSettings") - .field("integration_ids", &integration_ids) - .finish() + formatter.debug_map().entries(self.0.iter()).finish() } } -pub trait IntegrationConfig: DeserializeOwned + Validate { - fn is_enabled(&self) -> bool; - - /// Validate the public field schema for an explicitly disabled config. - /// - /// The default deserializes the integration's normal schema, except it - /// permits omitted enabled-only required fields. Override this only when a - /// disabled integration has a distinct public schema. - /// - /// # Errors - /// - /// Returns a deserialization error when the disabled public field schema is invalid. - fn validate_disabled_schema(raw: &JsonValue) -> Result<(), serde_json::Error> { - match serde_json::from_value::(raw.clone()) { - Ok(_) => Ok(()), - Err(error) if error.to_string().starts_with("missing field ") => Ok(()), - Err(error) => Err(error), +impl<'de> Deserialize<'de> for TypeSections { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let entries = serde_json::Map::::deserialize(deserializer)?; + let mut sections = BTreeMap::new(); + for (name, value) in entries { + let JsonValue::Object(table) = value else { + return Err(serde::de::Error::custom(format!( + "unknown field `{name}`, which is neither a setting Trusted Server reads nor \ + the section of a module type, which is a table" + ))); + }; + if name.contains('.') || !crate::module_name::is_valid(&name) { + return Err(serde::de::Error::custom(format!( + "unknown field `{name}`. The section of a module type is named for \ + the type, in lower case letters, digits, `_` or `-`" + ))); + } + let section = SectionModules::from_entries(table).map_err(|message| { + serde::de::Error::custom(format!( + "[{name}] is not a section Trusted Server reads itself, so it is read as \ + the section of a module type, and it {message}" + )) + })?; + sections.insert(name, section); } + Ok(Self(sections)) } } -impl IntegrationSettings { - /// Inserts a configuration value for an integration. - /// - /// # Errors - /// - /// Returns an error if the configuration cannot be serialized to JSON. - #[cfg_attr(not(test), allow(dead_code))] - pub fn insert_config( - &mut self, - integration_id: impl Into, - value: &T, - ) -> Result<(), Report> - where - T: Serialize, - { - let json = - serde_json::to_value(value).change_context(TrustedServerError::Configuration { - message: "Failed to serialize integration configuration".to_string(), - })?; - self.entries.insert(integration_id.into(), json); - Ok(()) +impl TypeSections { + /// Whether no type section is present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() } - fn is_explicitly_disabled(raw: &JsonValue) -> bool { - raw.as_object() - .and_then(|map| map.get("enabled")) - .and_then(JsonValue::as_bool) - == Some(false) + /// Each type section, by name. + pub fn iter(&self) -> impl Iterator { + self.0 + .iter() + .map(|(name, section)| (name.as_str(), section)) } - fn remove_legacy_static_secret_store_selectors(&mut self) { - let Some(datadome) = self - .entries - .get_mut("datadome") - .and_then(JsonValue::as_object_mut) - else { - return; - }; + /// The section of `type_name`, when present. + #[must_use] + pub fn section(&self, type_name: &str) -> Option<&SectionModules> { + self.0.get(type_name) + } - let mut removed = datadome.remove("server_side_key_secret_store").is_some(); - if let Some(bypass) = datadome - .get_mut("protection_test_bypass") - .and_then(JsonValue::as_object_mut) - { - removed |= bypass.remove("credential_secret_store").is_some(); - } - if removed { - log::warn!( - "DataDome secret-store selectors are deprecated and ignored; static credentials resolve through the default app-config secret store" - ); - } + /// The section of `type_name`, created empty when absent. + pub fn section_mut(&mut self, type_name: &str) -> &mut SectionModules { + self.0.entry(type_name.to_owned()).or_default() } - /// Retrieves and validates a typed configuration for an integration. + /// Refuses a section that selects nothing, and checks each selection. /// /// # Errors /// - /// Returns an error if the configuration cannot be parsed from JSON or fails validation. - pub fn get_typed( - &self, - integration_id: &str, - ) -> Result, Report> - where - T: IntegrationConfig, - { - let raw = match self.entries.get(integration_id) { - Some(value) => value, - None => return Ok(None), - }; - - if Self::is_explicitly_disabled(raw) { - T::validate_disabled_schema(raw).change_context(TrustedServerError::Configuration { - message: format!( - "Integration '{integration_id}' configuration could not be parsed" - ), - })?; - return Ok(None); - } - - let config: T = serde_json::from_value(raw.clone()).change_context( - TrustedServerError::Configuration { - message: format!( - "Integration '{integration_id}' configuration could not be parsed" - ), - }, - )?; - - // Field validation runs only for integrations that resolve to enabled. - // An integration whose `enabled` flag is omitted falls back to its - // serde default, which the explicit-`false` fast path above cannot - // observe. Validating before this check would reject documented - // template placeholders in sections that are not actually turned on. - if !config.is_enabled() { - return Ok(None); + /// Naming the first section at fault. + pub fn validate(&self) -> Result<(), Report> { + for (name, section) in &self.0 { + if section.selected().is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[{name}] selects no module. A module type's section names what runs \ + with `module` or `modules`, so select one or remove the section" + ), + })); + } + section + .validate(name) + .map_err(|message| Report::new(TrustedServerError::Configuration { message }))?; } - - config.validate().map_err(|err| { - Report::new(TrustedServerError::Configuration { - message: format!( - "Integration '{integration_id}' configuration failed validation: {err}" - ), - }) - })?; - - Ok(Some(config)) - } -} - -impl Deref for IntegrationSettings { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.entries + Ok(()) } } -impl DerefMut for IntegrationSettings { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.entries - } -} +/// The settings type a module reads from its table, beneath the section that +/// selects it. +/// +/// The type states which settings the module takes and how they are +/// validated, and nothing else. Whether the module runs is not its business, +/// because the section's selection names what runs. +pub trait IntegrationConfig: DeserializeOwned + Validate {} /// A partner (SSP, DSP, identity vendor) configured in `[[ec.partners]]`. /// @@ -537,15 +489,65 @@ impl EcPartner { /// /// Mapped from the `[ec]` TOML section. Controls EC identity generation, /// KV store names, and partner registry. -#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] +/// +/// Every key in the section other than the fields below is one module's +/// `[ec.]` settings table, held in +/// [`module_blocks`](Self::module_blocks), so the field names below are +/// reserved and cannot name a module. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct Ec { - /// Publisher passphrase used as HMAC key for EC generation. - #[validate(custom(function = Ec::validate_passphrase))] - pub passphrase: Redacted, + /// The name of the Edge Cookie identity module to activate. + /// + /// Set it in the `[ec]` TOML section, for example `"hmac"`. The name is + /// the module's implementation unless its `[ec.]` block names a + /// different one, in which case the name is a label of the operator's + /// choosing (see [`module_blocks`](Self::module_blocks)). Deployment + /// tooling can merge a `TRUSTED_SERVER__EC__MODULE` environment value + /// into the published configuration before it is loaded, so the same + /// compiled WebAssembly can switch modules at deployment. The running + /// server reads its settings from the platform config store, not the + /// environment. When absent, no Edge Cookie is generated and Trusted + /// Server runs statelessly, and the explicit `"none"` spells the same + /// choice. A selection whose implementation needs settings it has no block + /// for is rejected at startup by + /// [`validate_module_selection`](Self::validate_module_selection). + /// + /// Typed as [`EcModuleSelection`], which reads and writes the same + /// string, so every check that asks which module is selected matches on + /// one vocabulary rather than comparing string literals. + #[serde(default)] + pub module: Option, - /// Fastly KV store name for the EC identity graph. + /// Deprecated location of the HMAC passphrase, read so a configuration + /// written for the previous release still starts. + /// + /// [`migrate_legacy_ec_layout`](Self::migrate_legacy_ec_layout) maps it + /// to `module = "hmac"` with the passphrase in the `[ec.hmac]` block and + /// logs a deprecation warning, so a fleet can move configuration and + /// binaries independently. A configuration carrying both the old and the + /// new form is rejected rather than guessed at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option>, + + /// Extra exact origins allowed to POST the client resolve endpoint. + /// + /// The endpoint always accepts `https://{publisher.domain}` and nothing + /// else by default. A publisher whose pages are served from another origin, + /// `www` being the common case, lists those origins here. Each entry is a + /// serialized origin (RFC 6454 §6.1), being `http://` or `https://` and a + /// host with an optional port and nothing after it, and + /// [`validate_resolve_allowed_origins`](Self::validate_resolve_allowed_origins) + /// refuses any other entry when the settings load. Each is compared with + /// the request's `Origin` by the same-origin test of RFC 6454 §5, so the + /// scheme, the host and the port all have to match, with a missing port + /// meaning the scheme's default. A suffix or subdomain match is never + /// performed, because control of a DNS namespace does not make every host + /// under it a trusted identity-setting origin. #[serde(default)] + pub resolve_allowed_origins: Vec, + + /// Fastly KV store name for the EC identity graph. + #[serde(default, serialize_with = "crate::redacted::sensitive")] pub ec_store: Option, /// Maximum number of concurrent pull-sync requests. @@ -568,8 +570,20 @@ pub struct Ec { /// Partners (SSPs, DSPs, identity vendors) for EC identity sync. #[serde(default, deserialize_with = "vec_from_seq_or_map")] - #[validate(nested)] pub partners: Vec, + + /// The settings tables of the Edge Cookie identity modules, keyed by the + /// name each is written under. + /// + /// A module has a block only when it has settings of its own, so + /// `[ec] module = "hmac"` needs an `[ec.hmac]` block for its required + /// passphrase while a module with no settings needs none. The + /// [`module`](Self::module) selector names the active module and + /// [`validate_module_selection`](Self::validate_module_selection) + /// rejects a block it does not name, so at most one block survives + /// startup. + #[serde(flatten)] + pub module_blocks: EcModuleBlocks, } impl Ec { @@ -631,120 +645,944 @@ impl Ec { } Ok(()) } -} - -#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] -pub struct Rewrite { - /// List of domains to exclude from rewriting. Supports wildcards (e.g., "*.example.com"). - /// URLs from these domains will not be proxied through first-party endpoints. - #[serde(default)] - pub exclude_domains: Vec, -} - -impl Rewrite { - /// Checks if a URL should be excluded from rewriting based on domain matching - #[allow(dead_code)] - #[must_use] - pub fn is_excluded(&self, url: &str) -> bool { - // Parse URL to extract host - let Ok(parsed) = url::Url::parse(url) else { - return false; - }; - let host = parsed.host_str().unwrap_or(""); - - // Check exact domain matches (with wildcard support) - for domain in &self.exclude_domains { - if let Some(suffix) = domain.strip_prefix("*.") { - // Wildcard: *.example.com matches both example.com and sub.example.com - if host == suffix || host.ends_with(&format!(".{}", suffix)) { - return true; - } - } else if host == domain { - return true; + /// Refuses a [`resolve_allowed_origins`](Self::resolve_allowed_origins) + /// entry that is not a serialized origin. + /// + /// The resolve endpoint reads each entry with + /// [`parse_serialized_origin`](crate::ec::resolve::parse_serialized_origin), + /// and an entry that parse rejects matches no request, so every resolve + /// from the origin it was meant to allow would answer `403`. Checking with + /// the same parse here means an entry that loads is one a request can + /// match. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] naming the first entry + /// that is not a serialized origin and what is wrong with it. + pub fn validate_resolve_allowed_origins(&self) -> Result<(), Report> { + for entry in &self.resolve_allowed_origins { + if let Err(reason) = crate::ec::resolve::parse_serialized_origin(entry) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[ec] resolve_allowed_origins entry `{entry}` {reason}. \ + Each entry is a bare origin, being `http://` or `https://` and a \ + host with an optional port, such as `https://www.example.com`" + ), + })); } } - - false + Ok(()) } -} -#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] -pub struct Handler { - #[validate(length(min = 1), custom(function = validate_path))] - pub path: String, - #[validate(custom(function = validate_redacted_not_empty))] - pub username: Redacted, - #[validate(custom(function = validate_redacted_not_empty))] - pub password: Redacted, - #[serde(skip, default)] - #[validate(skip)] - regex: OnceLock>, -} + /// Validates the module selection against the configured blocks. + /// + /// When [`module`](Self::module) is set, the selection has to resolve + /// to an implementation this deployment can configure, and every block in + /// [`module_blocks`](Self::module_blocks) has to be the one the + /// selector names, so a deployment that selects a module (in TOML or via + /// the environment override) but has not configured it fails fast at + /// startup rather than silently running stateless. When no module is + /// selected, Trusted Server runs statelessly and this check passes. + /// + /// Whether the named implementation exists at all is settled by + /// [`build_module`](crate::ec::module::build_module), which is the + /// one place that knows both the implementations built into core and the + /// one this deployment's adapter injects. + /// + /// Whether this build compiles an implementation in at all is answered by + /// `check_named_module_configuration` in [`crate::ec::module`], beside + /// the resolution it belongs to, rather than here, because the settings + /// cannot know which implementations are compiled out of this build. Only + /// the resolution knows that. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when a module name or + /// implementation is not a module name, when a block is configured with no + /// selector or alongside `"none"`, when the selector names a key `[ec]` + /// reads as its own setting, when the selected implementation is not + /// compiled into this build, when a block the selector does not name is + /// configured, or when the selected module resolves to an implementation + /// that needs settings and has no block. + pub fn validate_module_selection(&self) -> Result<(), Report> { + for (name, block) in self.module_blocks.iter() { + Self::validate_module_name(name)?; + if let Some(implementation) = &block.implementation { + Self::validate_module_name(implementation)?; + } + } -impl Handler { - /// Known handler password placeholders that must not be used in deployments. - pub const PASSWORD_PLACEHOLDERS: &[&str] = &[ - "replace-with-admin-password-32-bytes", - "replace-with-admin-password", - "change-me-admin-password", - ]; + let Some(selection) = self.module.as_ref() else { + if !self.module_blocks.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec.] module blocks are configured but no [ec] module \ + is selected. Set [ec] module = \"\" to activate one, or \ + remove the blocks to run statelessly" + .to_owned(), + })); + } + return Ok(()); + }; - /// Returns `true` if `password` matches a known placeholder value - /// (case-insensitive). - #[must_use] - pub fn is_placeholder_password(password: &str) -> bool { - let password = password.trim(); - Self::PASSWORD_PLACEHOLDERS - .iter() - .any(|placeholder| placeholder.eq_ignore_ascii_case(password)) - } + // `"none"` is explicit statelessness, the same meaning as omitting the + // selector, spelled out. It is subject to the same rule that no + // module blocks may be left configured. + let EcModuleSelection::Named(name) = selection else { + if !self.module_blocks.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] module = \"none\" selects stateless operation, but \ + [ec.] module blocks are configured. Remove the blocks, \ + or select the module they configure" + .to_owned(), + })); + } + return Ok(()); + }; - fn compiled_regex(&self) -> Result<&Regex, Report> { - match self - .regex - .get_or_init(|| Regex::new(&self.path).map_err(|err| err.to_string())) + let name = name.as_str(); + Self::validate_module_name(name)?; + if EC_SECTION_KEYS.contains(&name) || name == REMOVED_EC_PROVIDERS_TABLE { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[ec] module = \"{name}\" names a key the `[ec]` section reads as its \ + own setting, so no module can be configured under it. Give the \ + module a name of its own" + ), + })); + } + + // Whether this build compiles the implementation in at all is the + // resolution's question, not the settings', so it is asked there. + let implementation = self.module_blocks.implementation(name); + check_named_module_configuration(implementation)?; + + // A module has a block only when it has settings, and core knows + // which of its own implementations need them. Both modules that + // derive an identifier at the edge take a passphrase, so both need one. + // The demonstration module is built from nothing and needs none. A + // module an adapter injects has the contents of its block read by + // that adapter when it builds the module, so core cannot say whether + // it needs one. + if (implementation == HMAC_MODULE_KEY || implementation == HOST_SIGNALS_MODULE_KEY) + && !self.module_blocks.contains_key(name) { - Ok(regex) => Ok(regex), - Err(message) => Err(Report::new(TrustedServerError::Configuration { + return Err(Report::new(TrustedServerError::Configuration { message: format!( - "Handler path regex `{}` failed to compile: {message}", - self.path + "Edge Cookie module `{name}` is selected but has no `[ec.{name}]` configuration" ), - })), + })); + } + + // Every configured block must be the selected one. An unreferenced + // block is almost always a mistake (a mistyped selector or a stale + // block), and accepting it silently invites configuration drift. + let unreferenced: Vec<&str> = self + .module_blocks + .keys() + .map(String::as_str) + .filter(|configured| *configured != name) + .collect(); + if unreferenced.is_empty() { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[ec.{}] is configured but `{name}` is selected. Remove the unselected \ + block, or correct the selector", + unreferenced.join("], [ec.") + ), + })) } } - /// Eagerly compile the handler regex to fail fast during startup. + /// Validates one module name or implementation id. /// /// # Errors /// - /// Returns a configuration error if the handler path regex does not compile. - pub fn prepare_runtime(&self) -> Result<(), Report> { - self.compiled_regex().map(|_| ()) + /// Returns [`TrustedServerError::Configuration`] when `name` is not a + /// module name, see [`crate::module_name::is_valid`]. + fn validate_module_name(name: &str) -> Result<(), Report> { + if crate::module_name::is_valid(name) { + return Ok(()); + } + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Edge Cookie module name `{name}` is not a module name, which is parts \ + joined by `.`, each of lower case letters, digits, `_` or `-`" + ), + })) } - /// Determine whether this handler applies to the request path. + /// Migrates the deprecated `[ec] passphrase` form to the module layout. + /// + /// A configuration still carrying the old key keeps working for one + /// release cycle, mapping to `module = "hmac"` with the passphrase in + /// the `[ec.hmac]` block, and a deprecation warning names the new + /// location. A configuration carrying both forms is rejected so a + /// half-edited file fails loudly instead of one form silently winning. + /// + /// The deprecated key is held to the same passphrase rules as the new + /// `[ec.hmac]` block. Validation runs before this migration and the + /// deprecated field carries no check of its own, so without the check here + /// a short or empty passphrase in the old location would start a + /// deployment that the new location rejects. /// /// # Errors /// - /// Returns a configuration error if the handler path regex does not compile. - pub fn matches_path(&self, path: &str) -> Result> { - self.compiled_regex().map(|regex| regex.is_match(path)) + /// Returns [`TrustedServerError::Configuration`] when both the deprecated + /// key and any part of the module configuration are present, or when the + /// deprecated passphrase fails [`Self::validate_passphrase`]. + pub fn migrate_legacy_ec_layout(&mut self) -> Result<(), Report> { + let Some(passphrase) = self.passphrase.take() else { + return Ok(()); + }; + if self.module.is_some() || !self.module_blocks.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] passphrase (deprecated) and the [ec] module configuration \ + are both present. Keep exactly one form, moving the passphrase to \ + [ec.hmac] and deleting the old key" + .to_owned(), + })); + } + Self::validate_passphrase(&passphrase).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[ec] passphrase (deprecated) is invalid ({err}). Use a random secret \ + of at least {} bytes, placed in [ec.hmac]", + Self::MIN_PASSPHRASE_LENGTH, + ), + }) + })?; + log::warn!( + "[ec] passphrase is deprecated. Move it to [ec.hmac] passphrase and set \ + [ec] module = \"hmac\"" + ); + self.module = Some(EcModuleSelection::from(HMAC_MODULE_KEY)); + self.module_blocks.insert( + HMAC_MODULE_KEY.to_owned(), + EcModuleBlock::from(HmacModuleConfig { passphrase }), + ); + Ok(()) } } -#[derive(Debug, Default, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct RequestSigning { - #[serde(default = "default_request_signing_enabled")] - pub enabled: bool, - pub config_store_id: String, - pub secret_store_id: String, -} - +impl Validate for Ec { + /// Validates the partner entries and the settings of every block that + /// configures a module built into core. + /// + /// Written out rather than derived because each block's errors are keyed + /// by the name the operator wrote the block under, so a passphrase + /// `[ec.primary]` rejects is reported at `ec.primary.passphrase`. A + /// derived nested validation would key them under this struct's own field + /// name, which is a path no configuration has. + fn validate(&self) -> Result<(), ValidationErrors> { + let mut errors = ValidationErrors::new(); + errors.merge_self("partners", self.partners.validate()); + let blocks = self + .module_blocks + .hmac_blocks() + .map(|(name, config)| (name, config.validate())) + .chain( + self.module_blocks + .host_signals_blocks() + .map(|(name, config)| (name, config.validate())), + ); + for (name, result) in blocks { + if let Err(block_errors) = result { + errors.errors_mut().insert( + Cow::Owned(name.to_owned()), + ValidationErrorsKind::Struct(Box::new(block_errors)), + ); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +/// The keys the `[ec]` section reads as its own settings. +/// +/// Every other key in the section is one module's `[ec.]` settings +/// table, so these names are reserved and cannot name a module. They are +/// written out because serde reads the [`Ec`] fields by name and has no way to +/// report the list back for an error message. +const EC_SECTION_KEYS: &[&str] = &[ + "module", + "passphrase", + "resolve_allowed_origins", + "ec_store", + "pull_sync_concurrency", + "cluster_trust_threshold", + "cluster_recheck_secs", + "partners", +]; + +/// The removed table that used to hold every module's settings. +const REMOVED_EC_PROVIDERS_TABLE: &str = "providers"; + +/// The key in a module block that names the implementation it configures. +/// +/// Anything reading a module block out of a serialized configuration rather +/// than out of [`EcModuleBlock`] reads the same key from here, so the two +/// cannot drift apart. +pub(crate) const MODULE_IMPLEMENTATION_KEY: &str = "implementation"; + +/// The `[ec.]` settings tables of the Edge Cookie identity modules, +/// keyed by the name each is written under. +/// +/// A module that has settings is configured in its own table, for example: +/// +/// ```toml +/// [ec] +/// module = "hmac" +/// +/// [ec.hmac] +/// passphrase = "replace-with-32-plus-byte-random-secret" +/// ``` +/// +/// A table may name the implementation it configures, which makes its own name +/// a label of the operator's choosing, so the same configuration can also be +/// written as: +/// +/// ```toml +/// [ec] +/// module = "primary" +/// +/// [ec.primary] +/// implementation = "hmac" +/// passphrase = "replace-with-32-plus-byte-random-secret" +/// ``` +/// +/// The active module is chosen by the [`Ec::module`] selector, and the one +/// table present must be the one it names (see +/// [`Ec::validate_module_selection`]). +#[derive(Debug, Default, Clone, Serialize)] +#[serde(transparent)] +pub struct EcModuleBlocks(BTreeMap); + +impl EcModuleBlocks { + /// The implementation the module `name` resolves to. + /// + /// A block may name the implementation it configures. Without one, and + /// when the module has no block at all, the module's name is its + /// implementation. + #[must_use] + pub fn implementation<'a>(&'a self, name: &'a str) -> &'a str { + self.0 + .get(name) + .and_then(|block| block.implementation.as_deref()) + .unwrap_or(name) + } + + /// Every configured block that sets the built-in HMAC module up, with + /// the name it is written under. + /// + /// The name is the label the operator chose, so a caller reporting one of + /// these settings names the path the operator wrote rather than the + /// implementation's own. + pub fn hmac_blocks(&self) -> impl Iterator { + self.0 + .iter() + .filter_map(|(name, block)| block.hmac_settings().map(|config| (name.as_str(), config))) + } + + /// Every configured block that sets the built-in host-signal module up, + /// with the name it is written under. + /// + /// The same contract as [`hmac_blocks`](Self::hmac_blocks), for the other + /// module core builds itself. + pub fn host_signals_blocks(&self) -> impl Iterator { + self.0.iter().filter_map(|(name, block)| { + block + .host_signals_settings() + .map(|config| (name.as_str(), config)) + }) + } +} + +impl Deref for EcModuleBlocks { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for EcModuleBlocks { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl<'de> Deserialize<'de> for EcModuleBlocks { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct BlocksVisitor; + + impl<'de> serde::de::Visitor<'de> for BlocksVisitor { + type Value = EcModuleBlocks; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("the `[ec.]` module settings tables") + } + + fn visit_map
(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_module_block::(&name, table)?); + } + Ok(EcModuleBlocks(blocks)) + } + } + + deserializer.deserialize_map(BlocksVisitor) + } +} + +/// Reads one key left over from the `[ec]` section as the module block it +/// names. +/// +/// This is where the `[ec]` section gets the unknown-key check that the fixed +/// fields lose by holding the module blocks alongside them. A key that is +/// not a table cannot be a module, so it is reported as the mistyped setting +/// it almost certainly is. +fn read_module_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 module'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.] module \ + settings table" + ))); + }; + let implementation = match table.remove(MODULE_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 module \ + implementation the block configures" + ))); + } + }; + + // The implementation decides how the rest of the table is read. Core reads + // its own modules' 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 module 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_MODULE_KEY => EcModuleSettings::Hmac( + serde_json::from_value(JsonValue::Object(table)).map_err(invalid)?, + ), + HOST_SIGNALS_MODULE_KEY => EcModuleSettings::HostSignals( + serde_json::from_value(JsonValue::Object(table)).map_err(invalid)?, + ), + _ => EcModuleSettings::Injected(table), + }; + Ok(EcModuleBlock { + implementation, + settings, + }) +} + +/// One module's `[ec.]` settings table. +#[derive(Debug, Clone, Serialize)] +pub struct EcModuleBlock { + /// 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 module 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: EcModuleSettings, +} + +impl EcModuleBlock { + /// The built-in HMAC module's settings, when this block configures it. + #[must_use] + pub fn hmac_settings(&self) -> Option<&HmacModuleConfig> { + match &self.settings { + EcModuleSettings::Hmac(config) => Some(config), + EcModuleSettings::HostSignals(_) | EcModuleSettings::Injected(_) => None, + } + } + + /// The built-in host-signal module's settings, when this block + /// configures it. + #[must_use] + pub fn host_signals_settings(&self) -> Option<&HostSignalsModuleConfig> { + match &self.settings { + EcModuleSettings::HostSignals(config) => Some(config), + EcModuleSettings::Hmac(_) | EcModuleSettings::Injected(_) => None, + } + } +} + +impl From for EcModuleBlock { + /// Builds the `[ec.hmac]` block, the built-in HMAC module configured + /// under its own name. A block under a label names its implementation + /// instead. + fn from(config: HmacModuleConfig) -> Self { + Self { + implementation: None, + settings: EcModuleSettings::Hmac(config), + } + } +} + +impl From for EcModuleBlock { + /// Builds the `[ec.host_signals]` block, the built-in host-signal module + /// configured under its own name. A block under a label names its + /// implementation instead. + fn from(config: HostSignalsModuleConfig) -> Self { + Self { + implementation: None, + settings: EcModuleSettings::HostSignals(config), + } + } +} + +/// The settings one module block holds, read according to the +/// implementation the block configures. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum EcModuleSettings { + /// The built-in HMAC module's settings. + Hmac(HmacModuleConfig), + + /// The built-in host-signal module's settings. + HostSignals(HostSignalsModuleConfig), + + /// The settings of a module an adapter injects, kept as the raw values + /// the block held. The adapter that builds the module deserializes them + /// into the vendor crate's own config type, so core never names a vendor + /// and a new module adds nothing here. + Injected(serde_json::Map), +} + +/// Configuration for the built-in HMAC Edge Cookie module. +/// +/// 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 HmacModuleConfig { + /// 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 module. +/// +/// 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 HostSignalsModuleConfig { + /// 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 +/// module classifies a request into device signals, mirroring the Edge +/// Cookie module 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 module to activate. + /// + /// Defaults to the built-in `builtin` module when absent, which classifies + /// from the User-Agent alone, so device classification itself makes no + /// host-specific call. The opt-in `fastly` module 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__module` environment variable so the same + /// compiled WebAssembly can switch modules at deployment. An unknown key is + /// rejected at startup by + /// [`validate_module_selection`](Self::validate_module_selection). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module: Option, +} + +impl DeviceConfig { + /// Returns the active device-detection module key, defaulting to the + /// built-in heuristic. + #[must_use] + pub fn module_key(&self) -> &str { + self.module.as_deref().unwrap_or("builtin") + } + + /// Validates the selected device-detection module. + /// + /// `builtin` and `fastly` are resolved by the core and the Fastly adapter. + /// Any other key names an integration module that declares a device + /// module, 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 module validation uniformly + /// across the three capabilities. + pub fn validate_module_selection(&self) -> Result<(), Report> { + Ok(()) + } +} + +/// Which permission signal modules run, and in what order. +/// +/// Mapped from the `[permission-signal]` TOML section, where `modules` +/// selects. `[ec]`, `[geo]` and `[device]` each name one module with +/// `module`, 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 `modules` names a list, and the order is the policy, because the +/// last module with an opinion decides. +/// +/// A module that gains settings will take them in a +/// `[permission-signal.]` block named for it. None of the modules that +/// ship has settings, so `modules` 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 modules to run, in order, named by their crate folders below + /// `crates/permission-signal`, for example `gpc`, `gpp`, `us-privacy`, + /// `tcf` and `mtm` for the five that ship. + /// + /// Absent means every module 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 module 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 module crates are + /// linked, so the check that each name matches an available module and + /// none is repeated happens at the adapter's composition root, through + /// [`build_permission_signal_modules`], and refuses startup rather than + /// silently ignoring a typo. + /// + /// [`build_permission_signal_modules`]: + /// crate::permission_signal::build_permission_signal_modules + #[serde(skip_serializing_if = "Option::is_none")] + pub modules: Option>, +} + +/// Read by hand rather than derived, so that `sources` and `module`, the keys +/// `modules` replaced, are 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 modules \ + to run, in order, in [permission-signal] modules instead", + )); + } + if section.contains_key("module") { + return Err(serde::de::Error::custom( + "[permission-signal] module is `modules` here, a list, because signals \ + compose. Name the modules to run, in order, in [permission-signal] \ + modules instead", + )); + } + if let Some(key) = section.keys().find(|key| key.as_str() != "modules") { + return Err(serde::de::Error::custom(format!( + "unknown field `{key}` in [permission-signal], expected `modules`. No \ + permission signal module 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 modules = match section.remove("modules") { + Some(value) => serde_json::from_value(value).map_err(serde::de::Error::custom)?, + None => None, + }; + Ok(Self { modules }) + } +} + +/// Geo / IP intelligence configuration. +/// +/// Mapped from the `[geo]` TOML section. Selects which module resolves a +/// client IP into [`GeoInfo`](crate::platform::GeoInfo), mirroring the Edge +/// Cookie module selection in [`Ec`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct GeoConfig { + /// The key of the geo module to activate. + /// + /// No module 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`. `module = "none"` spells + /// the same choice explicitly. The host platform's own geo lookup is + /// opt-in via `module = "platform"`. Override it with the + /// `TRUSTED_SERVER__geo__module` environment variable so the same compiled + /// WebAssembly can switch modules at deployment. An unknown key is rejected + /// at startup by + /// [`validate_module_selection`](Self::validate_module_selection). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module: Option, + + /// Acknowledges that, with no geo module, 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 module without a geo module 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 module is available in this build. + /// + /// No selector is valid and is the default, running without geolocation, + /// the same way the Edge Cookie module runs statelessly when none is + /// selected. The explicit `"none"` spells the same choice. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected module + /// key is not one this build provides. + pub fn validate_module_selection(&self) -> Result<(), Report> { + // Unset, `none` and `platform` are resolved by the core. Any other key + // names an integration module that declares a geo module, 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 module 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 module, 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 module is configured (the permission + /// model gates it by jurisdiction) and no geo module is selected, + /// [`assume_single_jurisdiction`](Self::assume_single_jurisdiction) must be + /// `true`. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when an Edge Cookie + /// module is configured, no geo module 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 module. Only an unset + // selector and the explicit `none` resolve nothing, so only those + // two leave every request on the declared jurisdiction. + let geo_disabled = matches!(self.module.as_deref(), None | Some("none")); + // The selector is a typed enum, so statelessness is the absent + // selector or the explicit `none`, matched rather than compared as a + // string. + let ec_active = !matches!(ec.module, None | Some(EcModuleSelection::None)); + if geo_disabled && ec_active && !self.assume_single_jurisdiction { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] module is configured but no [geo] module 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 module" + .to_owned(), + })); + } + Ok(()) + } +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct Rewrite { + /// List of domains to exclude from rewriting. Supports wildcards (e.g., "*.example.com"). + /// URLs from these domains will not be proxied through first-party endpoints. + #[serde(default)] + pub exclude_domains: Vec, +} + +impl Rewrite { + /// Checks if a URL should be excluded from rewriting based on domain matching + #[allow(dead_code)] + #[must_use] + pub fn is_excluded(&self, url: &str) -> bool { + // Parse URL to extract host + let Ok(parsed) = url::Url::parse(url) else { + return false; + }; + + let host = parsed.host_str().unwrap_or(""); + + // Check exact domain matches (with wildcard support) + for domain in &self.exclude_domains { + if let Some(suffix) = domain.strip_prefix("*.") { + // Wildcard: *.example.com matches both example.com and sub.example.com + if host == suffix || host.ends_with(&format!(".{}", suffix)) { + return true; + } + } else if host == domain { + return true; + } + } + + false + } +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct Handler { + #[serde(serialize_with = "crate::redacted::sensitive")] + #[validate(length(min = 1), custom(function = validate_path))] + pub path: String, + #[validate(custom(function = validate_redacted_not_empty))] + pub username: Redacted, + #[validate(custom(function = validate_redacted_not_empty))] + pub password: Redacted, + #[serde(skip, default)] + #[validate(skip)] + regex: OnceLock>, +} + +impl Handler { + /// Known handler password placeholders that must not be used in deployments. + pub const PASSWORD_PLACEHOLDERS: &[&str] = &[ + "replace-with-admin-password-32-bytes", + "replace-with-admin-password", + "change-me-admin-password", + ]; + + /// Returns `true` if `password` matches a known placeholder value + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_password(password: &str) -> bool { + let password = password.trim(); + Self::PASSWORD_PLACEHOLDERS + .iter() + .any(|placeholder| placeholder.eq_ignore_ascii_case(password)) + } + + fn compiled_regex(&self) -> Result<&Regex, Report> { + match self + .regex + .get_or_init(|| Regex::new(&self.path).map_err(|err| err.to_string())) + { + Ok(regex) => Ok(regex), + Err(message) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Handler path regex `{}` failed to compile: {message}", + self.path + ), + })), + } + } + + /// Eagerly compile the handler regex to fail fast during startup. + /// + /// # Errors + /// + /// Returns a configuration error if the handler path regex does not compile. + pub fn prepare_runtime(&self) -> Result<(), Report> { + self.compiled_regex().map(|_| ()) + } + + /// Determine whether this handler applies to the request path. + /// + /// # Errors + /// + /// Returns a configuration error if the handler path regex does not compile. + pub fn matches_path(&self, path: &str) -> Result> { + self.compiled_regex().map(|regex| regex.is_match(path)) + } +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RequestSigning { + #[serde(default = "default_request_signing_enabled")] + pub enabled: bool, + #[serde(serialize_with = "crate::redacted::sensitive")] + pub config_store_id: String, + #[serde(serialize_with = "crate::redacted::sensitive")] + pub secret_store_id: String, +} + impl RequestSigning { /// Reserved example store-id values from the config template, plus the /// empty string, that must not be deployed while request signing is enabled. @@ -1528,6 +2366,7 @@ pub struct ProxyAssetRoute { /// Only the scheme, host, and port are used. Any path or query configured on /// this URL is rejected because the incoming request path/query, or the /// configured rewrite result, replaces them at runtime. + #[serde(serialize_with = "crate::redacted::sensitive")] pub origin_url: String, /// Optional regex matched against the incoming request path before proxying. pub path_pattern: Option, @@ -1760,7 +2599,6 @@ impl ProxyAssetRoute { } #[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] pub struct Proxy { /// Enable TLS certificate verification when proxying to HTTPS origins. /// Defaults to true for secure production use. @@ -1776,13 +2614,17 @@ 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 `auction.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, /// Path-prefix-based asset proxy routes evaluated before publisher fallback. #[serde(default, deserialize_with = "vec_from_seq_or_map")] pub asset_routes: Vec, + /// The modules this section selects, with each one's settings in the + /// table at its name. + #[serde(flatten)] + pub modules: SectionModules, } fn default_certificate_check() -> bool { @@ -1803,6 +2645,7 @@ impl Default for Proxy { certificate_check: default_certificate_check(), allowed_domains: Vec::new(), asset_routes: Vec::new(), + modules: SectionModules::default(), } } } @@ -2672,6 +3515,96 @@ fn is_default_auction_debug_comment_options(value: &AuctionDebugCommentOptions) *value == AuctionDebugCommentOptions::default() } +// The module 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() +} + +/// Message a configuration still carrying the removed `[integrations]` table +/// is rejected with. +const REMOVED_INTEGRATIONS_TABLE_MESSAGE: &str = "Configuration table `[integrations]` was removed. Each module is selected in the \ + section of its type, as [] module = \"\" or modules = [\"\", ...], with its \ + settings in the table at its name, [.], 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)) + } +} + +/// A table that is no longer read under its old name, refused with directions +/// to its new one when a configuration still carries it. +macro_rules! refused_table { + ($(#[$doc:meta])* $name:ident => $message:expr) => { + $(#[$doc])* + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] + pub struct $name; + + impl<'de> Deserialize<'de> for $name { + 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. 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($message)) + } + } + }; +} + +refused_table! { + /// The `[adserver]` table, renamed `[ad-server]`. + RenamedAdServerTable => "Configuration table `[adserver]` is now `[ad-server]`. Select the \ + ad server with `[ad-server] module` and move its settings to `[ad-server.]` \ + unchanged" +} + +refused_table! { + /// The `[integration]` table, which is no longer read. + RemovedIntegrationTable => "Configuration table `[integration]` is no longer read. Each \ + module is selected in the section of its type, as [] module = \"\" or \ + modules = [\"\", ...], with its settings in the table at its name, \ + [.]" +} + +refused_table! { + /// The `[permission_signal]` table, renamed `[permission-signal]`. + RenamedPermissionSignalTable => "Configuration table `[permission_signal]` is now \ + `[permission-signal]`, named exactly as its folder crates/permission-signal. Move its \ + settings there unchanged" +} + /// Behavior of the `` auction dump. Only consulted when /// [`DebugConfig::auction_html_comment`] is true. /// @@ -2686,9 +3619,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")] @@ -2730,7 +3663,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, @@ -2955,7 +3888,6 @@ fn validate_trusted_client_ip_shared_secret( } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] pub struct Settings { #[validate(nested)] pub publisher: Publisher, @@ -2976,8 +3908,30 @@ 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, + /// The `[integration]` table, kept so a configuration carrying it is + /// told where modules are selected now. + #[serde(default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the removed table fails with directions" + )] + integration: RemovedIntegrationTable, + /// The sections of module types core does not read itself, such as + /// `[cmp]` or `[tag]`, each named for the type of module it selects. + #[serde(flatten)] + pub sections: TypeSections, #[serde(default, deserialize_with = "vec_from_seq_or_map")] #[validate(nested)] pub handlers: Vec, @@ -2990,6 +3944,26 @@ pub struct Settings { #[serde(default)] #[validate(nested)] pub auction: AuctionConfig, + /// The auction's demand sources. `[demand] modules` 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. `[ad-server] module` + /// selects it and `[ad-server.]` holds its settings. + #[serde( + rename = "ad-server", + default, + skip_serializing_if = "ProviderChoice::is_unset" + )] + pub adserver: ProviderChoice, + /// The `[adserver]` table, kept so a configuration carrying it is told + /// its new name. + #[serde(rename = "adserver", default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the renamed table fails with directions" + )] + renamed_adserver: RenamedAdServerTable, #[serde(default)] pub consent: ConsentConfig, #[serde(default)] @@ -3004,9 +3978,54 @@ 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( + rename = "permission-signal", + default, + skip_serializing_if = "is_default_permission_signal_config" + )] + #[validate(nested)] + pub permission_signal: PermissionSignalConfig, + /// The `[permission_signal]` table, kept so a configuration carrying it is + /// told its new name. + #[serde(rename = "permission_signal", default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the renamed table fails with directions" + )] + renamed_permission_signal: RenamedPermissionSignalTable, + /// What the configuration page at `/_ts/config` shows. + #[serde( + default, + skip_serializing_if = "crate::inspect::config::InspectConfig::is_default" + )] + pub inspect: crate::inspect::config::InspectConfig, + /// Where the loader wrote secrets, which the configuration view masks. + /// Never read from a document and never written to one. + #[serde(skip)] + resolved_secrets: crate::secret_resolution::ResolvedSecrets, } impl Settings { + /// Where the loader wrote secrets, for the configuration view. + #[must_use] + pub(crate) fn resolved_secrets(&self) -> &crate::secret_resolution::ResolvedSecrets { + &self.resolved_secrets + } + + /// Records where the loader wrote secrets. + pub(crate) fn set_resolved_secrets( + &mut self, + secrets: crate::secret_resolution::ResolvedSecrets, + ) { + self.resolved_secrets = secrets; + } + /// Creates a new [`Settings`] instance from a TOML string. /// /// # Errors @@ -3051,9 +4070,15 @@ impl Settings { /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields #[cfg(test)] pub fn from_toml_and_env(toml_str: &str) -> Result> { + // The build script compiles the git version in through this variable, + // and cargo sets it on a test process too. It is not a setting. + let variables = std::env::vars() + .filter(|(name, _)| name != "TRUSTED_SERVER__GIT_VERSION") + .collect(); let environment = Environment::default() .prefix(ENVIRONMENT_VARIABLE_PREFIX) - .separator(ENVIRONMENT_VARIABLE_SEPARATOR); + .separator(ENVIRONMENT_VARIABLE_SEPARATOR) + .source(Some(variables)); let toml = File::from_str(toml_str, FileFormat::Toml); let config = Config::builder() @@ -3084,8 +4109,6 @@ impl Settings { self.image_optimizer.normalize()?; self.debug.auction_html_comment_options.normalize(); self.tinybird.normalize(); - self.integrations - .remove_legacy_static_secret_store_selectors(); self.consent.validate(); Ok(()) } @@ -3106,9 +4129,35 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_ec_layout()?; + settings.ec.validate_module_selection()?; + settings.ec.validate_resolve_allowed_origins()?; + settings.validate_module_sections()?; + settings.device.validate_module_selection()?; + settings.geo.validate_module_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) } @@ -3190,8 +4239,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.module_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.module_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()); @@ -3459,19 +4515,183 @@ impl Settings { Ok(()) } - /// Retrieves the integration configuration of a specific type. + /// Every section that selects modules, with its name: `[proxy]`, + /// `[auction]` and each module type's own section. + pub fn module_sections(&self) -> impl Iterator { + [ + ("proxy", &self.proxy.modules), + ("auction", &self.auction.modules), + ] + .into_iter() + .chain(self.sections.iter()) + } + + /// The section that selects the module `name`, and the name as written + /// there, whether in full or with the section's type folder left off. + #[must_use] + pub fn module_selection(&self, name: &str) -> Option<(&str, &str)> { + self.module_sections().find_map(|(section, modules)| { + modules + .selected() + .iter() + .find(|written| crate::module_name::resolve(section, written, &[name]).is_some()) + .map(|written| (section, written.as_str())) + }) + } + + /// Whether a section selects the module `name`. + #[must_use] + pub fn selects_module(&self, name: &str) -> bool { + self.module_selection(name).is_some() + } + + /// The table `section` holds at the name `written`, as it was written, + /// or an empty one when the section holds none. + #[must_use] + pub fn section_table( + &self, + section: &str, + written: &str, + ) -> serde_json::Map { + self.module_sections() + .find(|(candidate, _)| *candidate == section) + .and_then(|(_, modules)| modules.settings_of(written)) + .cloned() + .unwrap_or_default() + } + + /// Reads and validates a selected module's settings, from the table at the + /// name it is written under beneath the section that selects it, or + /// returns `None` when no section selects it. + /// + /// A selected module with no table reads an empty one, so a module that + /// takes no settings runs on its selection alone and one that requires a + /// setting reports the setting it is missing. The parse and validation + /// messages carry the underlying error, because a report renders only its + /// outermost message in `ts config validate`. /// /// # Errors /// - /// Returns an error if the integration configuration exists but cannot be deserialized as the requested type. - pub fn integration_config( - &self, - integration_id: &str, - ) -> Result, Report> + /// When the table cannot be read as `T` or fails its validation, naming the + /// table. + pub fn module_config(&self, name: &str) -> Result, Report> where T: IntegrationConfig, { - self.integrations.get_typed(integration_id) + let Some((section, written)) = self.module_selection(name) else { + return Ok(None); + }; + let table = self + .module_sections() + .find(|(candidate, _)| *candidate == section) + .and_then(|(_, modules)| modules.settings_of(written)) + .cloned() + .unwrap_or_default(); + let config: T = serde_json::from_value(JsonValue::Object(table)).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("[{section}.{written}] could not be read: {error}"), + }) + })?; + config.validate().map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("[{section}.{written}] failed validation: {error}"), + }) + })?; + Ok(Some(config)) + } + + /// The modules `section` selects, to change, with the section created + /// empty when it is a module type's and absent. + fn section_modules_mut(&mut self, section: &str) -> &mut SectionModules { + match section { + "proxy" => &mut self.proxy.modules, + "auction" => &mut self.auction.modules, + _ => self.sections.section_mut(section), + } + } + + /// Selects the module `name` in `section`, written with the section's + /// type folder left off, with no table of its own. + pub fn select_module(&mut self, section: &str, name: &str) { + let written = crate::module_name::short_form(section, name).to_owned(); + self.section_modules_mut(section).select(&written); + } + + /// Stops selecting the module `name` in `section` and drops its table. + pub fn remove_module(&mut self, section: &str, name: &str) { + let written = crate::module_name::short_form(section, name).to_owned(); + self.section_modules_mut(section).remove(&written); + } + + /// Selects the module `name` in `section`, with `settings` as its table, + /// which is what writing both in a document does. + /// + /// The section is the name's type folder, except for the sections that + /// Trusted Server reads itself and that also select modules, where the + /// name is written in full. + /// + /// # Errors + /// + /// Returns an error if `settings` cannot be serialized to a table. + pub fn insert_module_config( + &mut self, + section: &str, + name: &str, + settings: &T, + ) -> Result<(), Report> + where + T: Serialize, + { + let JsonValue::Object(table) = + serde_json::to_value(settings).change_context(TrustedServerError::Configuration { + message: "Failed to serialize module configuration".to_string(), + })? + else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("the settings of `{name}` are not a table"), + })); + }; + let written = crate::module_name::short_form(section, name).to_owned(); + self.section_modules_mut(section).insert(&written, table); + Ok(()) + } + + /// Checks every section's selection and tables, and that no module is + /// selected in two sections. + /// + /// # Errors + /// + /// Naming the section at fault. + pub fn validate_module_sections(&self) -> Result<(), Report> { + self.sections.validate()?; + for (section, modules) in [ + ("proxy", &self.proxy.modules), + ("auction", &self.auction.modules), + ] { + modules + .validate(section) + .map_err(|message| Report::new(TrustedServerError::Configuration { message }))?; + } + let mut seen: Vec<(&str, String)> = Vec::new(); + for (section, modules) in self.module_sections() { + for written in modules.selected() { + let full = if written.contains('.') { + written.clone() + } else { + format!("{section}.{written}") + }; + if let Some((earlier, _)) = seen.iter().find(|(_, name)| *name == full) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "`{written}` is selected in both [{earlier}] and [{section}]. A \ + module is selected in one section" + ), + })); + } + seen.push((section, full)); + } + } + Ok(()) } } @@ -3678,7 +4898,7 @@ where // Helper: allow Vec fields to deserialize from either a JSON array or a map of numeric indices. // This lets env vars such as -// TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS__0=example-browser work; +// TRUSTED_SERVER__AUCTION__PREBID__CLIENT_SIDE_BIDDERS__0=example-browser work; // the config env source represents the value as an object rather than a sequence. // String inputs may also be JSON arrays or comma-separated values. /// Deserializes a `HashMap` from either: @@ -3740,7 +4960,18 @@ where } } -pub(crate) fn vec_from_seq_or_map<'de, D, T>(deserializer: D) -> Result, D::Error> +/// Reads a list setting written as a sequence, as a map keyed by position, +/// or as a string holding a JSON array or comma-separated values. +/// +/// The map and string forms are what an environment overlay produces, so a +/// list reads the same from a file and from the environment. Public so a +/// module crate's own list settings read the same way. +/// +/// # Errors +/// +/// When the value is none of those forms, a map key is not a position, or an +/// item cannot be read as `T`. +pub fn vec_from_seq_or_map<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, T: DeserializeOwned, @@ -3816,7 +5047,8 @@ mod tests { use regex::Regex; use serde_json::json; use std::collections::BTreeSet; - use std::sync::Arc; + + use crate::ec::resolve::NotAnOrigin; /// `DebugConfig` denies unknown fields, so a binary built before /// `sandbox_metrics_enabled` existed must still accept a default blob. @@ -3869,13 +5101,12 @@ mod tests { ); } - use crate::auction::build_orchestrator; - use crate::integrations::{ - IntegrationRegistry, gpt::GptConfig, nextjs::NextJsIntegrationConfig, - prebid::PrebidIntegrationConfig, - }; + use crate::integrations::IntegrationRegistry; 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_module, + }; fn trusted_client_ip_toml(ip_header: &str, auth_header: &str, shared_secret: &str) -> String { format!( @@ -3956,15 +5187,79 @@ 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.sections = TypeSections::default(); + settings.auction.modules.clear(); + 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 module), 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 module"); + assert!( + geo.get("module").is_none(), + "an unset geo selector should not be serialized, got {geo}" + ); + assert!( + value + .get("device") + .is_none_or(|device| device.get("module").is_none()), + "an unset device selector should not be serialized, got {value}" + ); + } + + #[test] + fn a_selected_geo_module_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] +module = \"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/module") + .and_then(serde_json::Value::as_str), + Some("none"), + "a selected geo module 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( @@ -4020,12 +5315,12 @@ mod tests { const CANARY_S3_SESSION_TOKEN: &str = "CANARY-S3-SESSION-TOKEN-0123456789"; const CANARY_TINYBIRD_AUCTION_TOKEN: &str = "CANARY-TINYBIRD-AUCTION-TOKEN-0123456789"; const CANARY_TINYBIRD_ACCESS_TOKEN: &str = "CANARY-TINYBIRD-ACCESS-TOKEN-0123456789"; - const CANARY_DATADOME_SERVER_SIDE_KEY: &str = "CANARY-DATADOME-SERVER-SIDE-KEY-0123456789"; + const CANARY_MODULE_KEY: &str = "CANARY-MODULE-TABLE-KEY-0123456789"; let mut settings = create_test_settings(); settings.publisher.proxy_secret = Redacted::new(CANARY_PROXY_SECRET.to_string()); - settings.ec.passphrase = Redacted::new(CANARY_EC_PASSPHRASE.to_string()); + select_hmac_module(&mut settings.ec, HMAC_MODULE_KEY, CANARY_EC_PASSPHRASE); settings.handlers = vec![Handler { path: "^/secure".to_string(), @@ -4079,20 +5374,18 @@ mod tests { max_body_bytes: 0, }; - // `IntegrationSettings` stores integration configs as opaque JSON and - // relies on a hand-written `Debug` impl to suppress their values. That - // impl is the only thing keeping resolved DataDome credentials out of - // this output, so pin it here. + // A module's table is opaque JSON, and the section's hand-written + // `Debug` impl is the only thing keeping a secret a module's table + // holds out of this output, so pin it here. settings - .integrations - .insert_config( - "datadome", + .insert_module_config( + "testing", + "testing.example", &json!({ - "enabled": true, - "server_side_key_secret_name": CANARY_DATADOME_SERVER_SIDE_KEY, + "key_name": CANARY_MODULE_KEY, }), ) - .expect("should insert datadome integration config"); + .expect("should insert a module's table"); let debug = format!("{settings:?}"); @@ -4107,7 +5400,7 @@ mod tests { let canaries = [ ("publisher.proxy_secret", CANARY_PROXY_SECRET), - ("ec.passphrase", CANARY_EC_PASSPHRASE), + ("ec.hmac.passphrase", CANARY_EC_PASSPHRASE), ("handlers[].username", CANARY_HANDLER_USERNAME), ("handlers[].password", CANARY_HANDLER_PASSWORD), ("ec.partners[].api_token", CANARY_EC_PARTNER_API_TOKEN), @@ -4136,10 +5429,7 @@ mod tests { CANARY_TINYBIRD_AUCTION_TOKEN, ), ("tinybird.access_token_secret", CANARY_TINYBIRD_ACCESS_TOKEN), - ( - "integrations.datadome.server_side_key_secret_name", - CANARY_DATADOME_SERVER_SIDE_KEY, - ), + ("testing.example.key_name", CANARY_MODULE_KEY), ]; for (field, canary) in canaries { @@ -4556,7 +5846,7 @@ mod tests { .expect("should load the test settings fixture"); let mut value = serde_json::to_value(settings) .expect("should serialize the test settings fixture to JSON"); - value["auction"]["providers"] = json!(["prebid"]); + value["auction"]["providers"] = json!(["example"]); let error = Settings::from_json_value(value) .expect_err("should reject the removed auction provider list schema"); @@ -4566,16 +5856,17 @@ 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] modules"), + "error should name where the setting moved to, got {rendered}" ); } #[test] fn toml_settings_reject_legacy_auction_provider_list_with_migration_guidance() { let toml = format!( - "{}\n[auction]\nproviders = [\"prebid\"]\n", + "{}\n", crate_test_settings_str() + .replace("[auction]\n", "[auction]\nproviders = [\"example\"]\n") ); let error = Settings::from_toml(&toml) @@ -4586,8 +5877,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] modules"), + "error should name where the setting moved to, got {rendered}" ); } @@ -4595,7 +5886,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, @@ -4853,27 +6144,16 @@ mod tests { assert!(settings.is_ok()); let settings = settings.expect("should parse valid TOML"); - let prebid_cfg = settings - .integration_config::("prebid") - .expect("Prebid config query should succeed") - .expect("Prebid config should load from test settings"); - assert_eq!(prebid_cfg.timeout_ms, 1000); assert!( settings - .integration_config::("nextjs") - .expect("Next.js config query should succeed") + .module_config::(ENDPOINT_MODULE) + .expect("a query for a module no section selects should succeed") .is_none(), - "Next.js integration should default to disabled" + "a module no section selects 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" + assert!( + settings.auction.modules.selected().is_empty(), + "the fixture should select no auction module" ); assert_eq!(settings.publisher.domain, "test-publisher.com"); assert_eq!(settings.publisher.cookie_domain, ".test-publisher.com"); @@ -4892,7 +6172,12 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.passphrase.expose(), + settings.ec.module.as_ref(), + Some(&EcModuleSelection::from(HMAC_MODULE_KEY)), + "test settings should select the hmac EC module" + ); + assert_eq!( + hmac_passphrase(&settings.ec, HMAC_MODULE_KEY), "test-secret-key-32-bytes-minimum" ); @@ -5080,43 +6365,177 @@ mod tests { crate_test_settings_str() ); - for path in [ - "/assets/hero-Portrait.jpg", - "/assets/logo-DarkMode.svg", - "/assets/banner-Summer24.png", - ] { - let error = Settings::from_toml(&rule) - .expect_err("should reject immutable Vite-style cache rule"); + for path in [ + "/assets/hero-Portrait.jpg", + "/assets/logo-DarkMode.svg", + "/assets/banner-Summer24.png", + ] { + let error = Settings::from_toml(&rule) + .expect_err("should reject immutable Vite-style cache rule"); + assert!( + format!("{error:?}").contains("cannot set immutable with vite-base64-url"), + "{path} must not receive an immutable policy through a Vite-style rule" + ); + } + } + + #[test] + fn non_immutable_vite_style_remains_available_for_cache_matching() { + let toml = format!( + r#"{} + + [[cache.asset_rules]] + id = "vite-assets" + enabled = true + path_glob = "/assets/*.js" + fingerprint_style = "vite-base64-url" + browser_ttl_seconds = 300 + "#, + crate_test_settings_str() + ); + let settings = + Settings::from_toml(&toml).expect("should allow Vite-style matching without immutable"); + + assert!( + settings + .asset_cache_policy_for_path("/assets/index-BsELY24f.js") + .expect("should evaluate Vite-style cache rule") + .is_some(), + "non-immutable Vite-style rule should still match a Vite output filename" + ); + } + + #[test] + fn module_selection_allows_no_module_for_stateless_operation() { + let ec = Ec::default(); + assert!(ec.module.is_none(), "default Ec selects no module"); + ec.validate_module_selection() + .expect("should allow no module selected and run statelessly"); + } + + #[test] + fn selecting_an_implementation_that_needs_settings_without_its_block_is_rejected() { + // The built-in HMAC module 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]\nmodule = \"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_module_with_no_settings_needs_no_block() { + // A block exists only when a module has settings, and only the + // adapter that injects a module knows whether it has any, so a + // selector naming one core does not supply is left to + // `build_module`, which is where the injected modules are known. + let toml_str = crate_test_settings_str_with_ec_section("[ec]\nmodule = \"acme\"\n"); + + let settings = + Settings::from_toml(&toml_str).expect("a module with no settings should need no block"); + assert!( + settings.ec.module_blocks.is_empty(), + "the selection should stand on its own with no block configured" + ); + } + + /// The fixture settings with `line` added to `[ec]`. + fn settings_toml_with_ec_line(line: &str) -> String { + crate_test_settings_str_with_ec_section(&format!( + "[ec]\nmodule = \"hmac\"\n{line}\n\n[ec.hmac]\n\ + passphrase = \"test-secret-key-32-bytes-minimum\"\n" + )) + } + + /// The `resolve_allowed_origins` line listing `entries`, each written as a + /// TOML basic string. + fn resolve_allowed_origins_line(entries: &[&str]) -> String { + let written: Vec = entries + .iter() + .map(|entry| serde_json::to_string(entry).expect("should write a string")) + .collect(); + format!("resolve_allowed_origins = [{}]", written.join(", ")) + } + + #[test] + fn resolve_allowed_origins_load_only_as_bare_origins() { + let accepted: [(&str, Option<&[&str]>); 7] = [ + ("an absent list", None), + ("an empty list", Some(&[])), + ("https with no port", Some(&["https://www.example.com"])), + ("https with :443", Some(&["https://www.example.com:443"])), + ("http with :80", Some(&["http://www.example.com:80"])), + ( + "a non-default port", + Some(&["https://www.example.com:8443"]), + ), + ( + "the generator's https://{domain} shape", + Some(&["https://www.example.com", "https://m.example.com"]), + ), + ]; + for (label, entries) in accepted { + let line = entries + .map(resolve_allowed_origins_line) + .unwrap_or_default(); + + let settings = Settings::from_toml(&settings_toml_with_ec_line(&line)) + .unwrap_or_else(|err| panic!("should load {label}: {err:?}")); + + assert_eq!( + settings.ec.resolve_allowed_origins, + entries.unwrap_or_default(), + "should keep the entries of {label} as written" + ); + } + + let refused = [ + ("https://www.example.com/", NotAnOrigin::Path), + ("https://www.example.com/path", NotAnOrigin::Path), + ("https://www.example.com\\", NotAnOrigin::Path), + ("https://www.example.com?a=1", NotAnOrigin::Query), + ("https://www.example.com#top", NotAnOrigin::Fragment), + ("https://user@www.example.com", NotAnOrigin::Userinfo), + ("https://user:pass@www.example.com", NotAnOrigin::Userinfo), + ("www.example.com", NotAnOrigin::Scheme), + ("www.example.com:443", NotAnOrigin::Scheme), + ("ftp://www.example.com", NotAnOrigin::Scheme), + ("wss://www.example.com", NotAnOrigin::Scheme), + ("null", NotAnOrigin::Scheme), + ("", NotAnOrigin::Empty), + ("https://", NotAnOrigin::Host), + ("https://www.example.com ", NotAnOrigin::Host), + ]; + for (entry, reason) in refused { + let line = resolve_allowed_origins_line(&["https://www.example.com", entry]); + + let Err(err) = Settings::from_toml(&settings_toml_with_ec_line(&line)) else { + panic!("should refuse `{entry}` at load"); + }; + assert!( - format!("{error:?}").contains("cannot set immutable with vite-base64-url"), - "{path} must not receive an immutable policy through a Vite-style rule" + format!("{err:?}") + .contains(&format!("resolve_allowed_origins entry `{entry}` {reason}")), + "should name `{entry}` and say it {reason}: {err:?}" ); } } #[test] - fn non_immutable_vite_style_remains_available_for_cache_matching() { - let toml = format!( - r#"{} + fn a_resolve_allowed_origin_with_a_trailing_slash_is_refused_at_load() { + let line = resolve_allowed_origins_line(&["https://www.example.com/"]); - [[cache.asset_rules]] - id = "vite-assets" - enabled = true - path_glob = "/assets/*.js" - fingerprint_style = "vite-base64-url" - browser_ttl_seconds = 300 - "#, - crate_test_settings_str() - ); - let settings = - Settings::from_toml(&toml).expect("should allow Vite-style matching without immutable"); + let err = Settings::from_toml(&settings_toml_with_ec_line(&line)) + .expect_err("should refuse an entry that would never match a request's Origin"); assert!( - settings - .asset_cache_policy_for_path("/assets/index-BsELY24f.js") - .expect("should evaluate Vite-style cache rule") - .is_some(), - "non-immutable Vite-style rule should still match a Vite output filename" + format!("{err:?}").contains("`https://www.example.com/`"), + "should name the entry: {err:?}" ); } @@ -5226,6 +6645,25 @@ mod tests { ); } + #[test] + fn module_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("module = \"hmac\"\n", ""); + + let err = Settings::from_toml(&toml_str) + .expect_err("a module 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!( @@ -5339,6 +6777,78 @@ mod tests { ); } + #[test] + fn legacy_passphrase_migrates_to_the_hmac_module() { + 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.module.as_ref(), + Some(&EcModuleSelection::from(HMAC_MODULE_KEY)), + "the deprecated passphrase should select the hmac module" + ); + assert_eq!( + hmac_passphrase(&ec, HMAC_MODULE_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 module 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.module.as_ref(), + Some(&EcModuleSelection::from(HMAC_MODULE_KEY)), + "an adequate legacy passphrase should still select the hmac module" + ); + assert_eq!( + hmac_passphrase(&settings.ec, HMAC_MODULE_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!( @@ -5411,9 +6921,491 @@ mod tests { 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:?}" + format!("{missing_matcher_err:?}").contains("exactly one matcher"), + "should explain missing matcher: {missing_matcher_err:?}" + ); + } + + #[test] + fn legacy_passphrase_alongside_module_config_is_rejected() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + module: Some(EcModuleSelection::from(HMAC_MODULE_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_module_block_is_rejected() { + // A mistyped key dropped silently would leave 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 module_none_is_explicit_stateless() { + let ec = Ec { + module: Some(EcModuleSelection::None), + ..Ec::default() + }; + ec.validate_module_selection() + .expect("explicit none with no blocks should be valid"); + } + + #[test] + fn module_none_with_configured_blocks_is_rejected() { + let mut ec = Ec::default(); + select_hmac_module(&mut ec, HMAC_MODULE_KEY, "test-secret-key-32-bytes-minimum"); + ec.module = Some(EcModuleSelection::None); + assert!( + ec.validate_module_selection().is_err(), + "none alongside configured blocks should be rejected" + ); + } + + #[test] + fn device_module_defaults_to_builtin_and_rejects_unknown() { + let config = DeviceConfig::default(); + assert_eq!( + config.module_key(), + "builtin", + "no selector should default to the built-in module" + ); + config + .validate_module_selection() + .expect("should validate the built-in default"); + + let fastly = DeviceConfig { + module: Some("fastly".to_owned()), + }; + fastly + .validate_module_selection() + .expect("should validate the fastly opt-in"); + + // As with geo, a key core does not know is not a settings error, + // because a module's name is a legitimate value here too. + let module_key = DeviceConfig { + module: Some("acme".to_owned()), + }; + module_key + .validate_module_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 module in + // silence. + let mut settings = crate::test_support::tests::create_test_settings(); + settings.device.module = Some("acme".to_owned()); + let error = match crate::integrations::IntegrationRegistry::new(&settings) { + Ok(_) => { + panic!("a device module no module supplies should be rejected at registry build") + } + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("acme") && message.contains("[device] module"), + "the error should name the selector and the module, got: {message}" + ); + } + + #[test] + fn an_unselected_module_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( + "module = \"hmac\"", + "module = \"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 a_host_signals_block_is_read_as_the_built_in_modules_settings() { + // The block is the built-in module's own settings, rather than kept as + // the raw values of a module an adapter injects. + let settings = Settings::from_toml(&crate_test_settings_str_with_ec_section(&format!( + "[ec] +module = \"{HOST_SIGNALS_MODULE_KEY}\" + +\ + [ec.{HOST_SIGNALS_MODULE_KEY}] +\ + passphrase = \"test-secret-key-32-bytes-minimum\" +" + ))) + .expect("a host_signals selection with its block should load"); + assert!( + settings + .ec + .module_blocks + .get(HOST_SIGNALS_MODULE_KEY) + .and_then(EcModuleBlock::host_signals_settings) + .is_some(), + "the block should be read as the host-signal module's settings" + ); + } + #[test] + fn a_labeled_block_the_selector_does_not_name_is_rejected() { + // A block under a label is still a module 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] +module = "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]\nmodule = \"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 module 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]\nmodule = \"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_module_accepts_default_platform_and_none_and_rejects_unknown() { + let config = GeoConfig::default(); + assert!( + config.module.is_none(), + "geo should default to no selector, which selects no geo module" + ); + config + .validate_module_selection() + .expect("should validate the default of running without geolocation"); + + let platform = GeoConfig { + module: Some("platform".to_owned()), + assume_single_jurisdiction: false, + }; + platform + .validate_module_selection() + .expect("should validate the explicit platform selection"); + + let none = GeoConfig { + module: Some("none".to_owned()), + assume_single_jurisdiction: false, + }; + none.validate_module_selection() + .expect("should validate the explicit opt-out of geolocation"); + + // A key core does not know is not a settings error, because a + // module's name 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 { + module: Some("acme".to_owned()), + assume_single_jurisdiction: false, + }; + module_key + .validate_module_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.module = 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 module no module supplies should be rejected at registry build") + } + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("acme") && message.contains("[geo] module"), + "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_module_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_module() { + let toml_str = crate_test_settings_str_with_ec_section("[ec]\nmodule = \"ec_store\"\n"); + + let err = + Settings::from_toml(&toml_str).expect_err("a reserved key should not name a module"); + 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 module_names_and_implementations_follow_the_module_name_rule() { + for ec_section in [ + "[ec]\nmodule = \"Primary\"\n", + "[ec]\nmodule = \"primary\"\n\n[ec.primary]\nimplementation = \"Hmac\"\n", + "[ec]\nmodule = \"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 that is not a module name should be rejected"); + assert!( + format!("{err:?}").contains("is not a module name"), + "should hold `{ec_section}` to the module name 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]\nmodule = \"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]\nmodule = \"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 module 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" + ); + + let reparsed = + Settings::from_json_value(value).expect("should reparse the serialized settings"); + assert_eq!( + reparsed.ec.module_blocks.implementation("primary"), + HMAC_MODULE_KEY, + "the implementation should survive the round trip" + ); + 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!( + written["ec"]["hmac"].get("implementation").is_none(), + "an unlabeled block should not gain an implementation key: {}", + written["ec"]["hmac"] + ); + } + + #[test] + fn an_injected_modules_settings_are_kept_as_written() { + // Core never names a vendor, so the block of a module 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]\nmodule = \"acme\"\n\n[ec.acme]\nendpoint = \"https://ec.acme.example.com\"\n", + ); + let settings = Settings::from_toml(&toml_str).expect("should parse a vendor module block"); + + let block = settings + .ec + .module_blocks + .get("acme") + .expect("should configure the acme block"); + assert_eq!( + settings.ec.module_blocks.implementation("acme"), + "acme", + "a block that names no implementation is its own name's" + ); + let EcModuleSettings::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 module is configured and + // no geo module 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 module with no geo module needs the acknowledgment"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() ); + + // Selecting a geo module removes the requirement. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("[geo]", "[geo]\n module = \"platform\""); + Settings::from_toml(&toml_str) + .expect("a geo module resolves jurisdictions, so no acknowledgment is needed"); + + // With no EC module there is no jurisdiction consumer to protect. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("module = \"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] @@ -5828,7 +7820,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_module( + &mut settings.ec, + HMAC_MODULE_KEY, + "test-secret-key-32-bytes-minimum", + ); settings.handlers[0].password = Redacted::new("replace-with-admin-password-32-bytes".to_owned()); @@ -5867,7 +7863,6 @@ 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()); settings.ec.partners = vec![test_partner_with_pull_token( "partner-api-token-32-bytes-minimum", )]; @@ -5886,7 +7881,6 @@ 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()); settings.ec.partners = vec![test_partner_with_pull_token( "unit-test-realistic-pull-sync-token-32-bytes-min", )]; @@ -5901,7 +7895,6 @@ 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()); settings.ec.partners = vec![test_partner_with_tokens( Some("partner-api-token-32-bytes-minimum"), "replace-with-partner-api-token-32-bytes-minimum", @@ -6466,7 +8459,13 @@ source_domain = "partner.example.com" proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -6496,155 +8495,261 @@ source_domain = "partner.example.com" proxy_secret = "unit-test-proxy-secret" max_buffered_body_bytes = 0 + [geo] + assume_single_jurisdiction = true + [ec] + module = "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}" ); } - #[test] - fn test_disabled_integration_does_not_register() { - use crate::integrations::testlight::TestlightConfig; - use serde_json::json; + /// The settings of an example module that takes none. + #[derive(Debug, Deserialize, Validate)] + #[serde(deny_unknown_fields)] + struct NoSettings {} - 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"); + impl IntegrationConfig for NoSettings {} - let config = settings - .integration_config::("testlight") - .expect("integration parsing should succeed"); + /// The settings of an example module that requires one. + #[derive(Debug, Deserialize, Validate)] + #[serde(deny_unknown_fields)] + struct OneRequiredSetting { + #[validate(url)] + endpoint: String, + } + + impl IntegrationConfig for OneRequiredSetting {} - assert!(config.is_none(), "Disabled integrations should be skipped"); + /// The example modules' names, in a section of their own type. + const EXAMPLE_SECTION: &str = "example"; + const PLAIN_MODULE: &str = "example.plain"; + const ENDPOINT_MODULE: &str = "example.endpoint"; + + /// A module no section selects has no configuration, whatever else the + /// settings hold. + #[test] + fn a_module_that_is_not_selected_has_no_configuration() { + let settings = create_test_settings(); + + assert!( + !settings.selects_module(ENDPOINT_MODULE), + "the shared fixture should not select the example module" + ); + assert!( + settings + .module_config::(ENDPOINT_MODULE) + .expect("reading an unselected module should succeed") + .is_none(), + "a module that is not selected should have no configuration" + ); } + /// A selected module with no table of its own is read from an empty one, + /// so one that takes no settings runs on its selection alone. #[test] - fn disabled_integration_can_omit_enabled_required_fields_and_skip_semantic_validation() { + fn a_selected_module_with_no_table_is_read_from_an_empty_one() { let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "gpt", - &json!({ - "enabled": false, - }), - ) - .expect("should insert GPT config"); + settings.select_module(EXAMPLE_SECTION, PLAIN_MODULE); - 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 + .module_config::(PLAIN_MODULE) + .expect("a module that takes no settings should read from an empty table") + .is_some(), + "selecting the module should be the whole configuration" + ); } + /// The same empty table makes a module 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_selected_module_without_a_required_setting_names_it() { let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "prebid", - &json!({ - "enabled": false, - }), - ) - .expect("should insert prebid config"); + settings.select_module(EXAMPLE_SECTION, ENDPOINT_MODULE); - 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"), + let error = settings + .module_config::(ENDPOINT_MODULE) + .expect_err("should reject a selected module with no endpoint"); + + let rendered = error.to_string(); + assert!( + rendered.contains("[example.endpoint]") && rendered.contains("endpoint"), + "should name the module's table and the missing setting: {rendered}" + ); + } + + /// A table written for a module its section does not select is refused, + /// rather than sitting in the configuration doing nothing. + #[test] + fn a_table_for_a_module_that_is_not_selected_is_refused() { + let toml = format!( + "{}\n[testing]\nmodule = \"example\"\n\n[testing.another]\n", + crate_test_settings_str() + ); + + let error = Settings::from_toml(&toml).expect_err("should reject a table nothing selects"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("[testing.another] is configured") + && rendered.contains("does not select"), + "should name the table and the selection it is missing from: {rendered}" + ); + } + + /// A module type's section that selects nothing is refused, which is also + /// what a misspelt section of Trusted Server's own meets. + #[test] + fn a_section_that_selects_no_module_is_refused() { + let toml = format!( + "{}\n[framework.nextjs]\nrewrite_attributes = [\"href\"]\n", + crate_test_settings_str() + ); + + let error = + Settings::from_toml(&toml).expect_err("should reject a section selecting nothing"); + + assert!( + format!("{error:?}").contains("[framework] selects no module"), + "should name the section: {error:?}" + ); + } + + /// A key left in a module's table that its settings do not have, such as + /// the removed `enabled`, is refused when the module reads its table, so a + /// configuration cannot read as switched off while the module runs. + #[test] + fn an_enabled_key_left_in_a_table_is_refused() { + let toml = format!( + "{}\n[example]\nmodules = [\"endpoint\"]\n\n[example.endpoint]\nendpoint = \"https://endpoint.example\"\nenabled = false\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml).expect("core reads no module's table itself"); + + let error = settings + .module_config::(ENDPOINT_MODULE) + .expect_err("should reject a leftover enabled key"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("[example.endpoint]") && rendered.contains("enabled"), + "should name the table and the key: {rendered}" + ); + } + + /// Naming one module twice is a mistake rather than a way of running it + /// twice, so it is refused. + #[test] + fn naming_a_module_twice_is_refused() { + let toml = format!( + "{}\n[example]\nmodules = [\"endpoint\", \"endpoint\"]\n", + crate_test_settings_str() + ); + + let error = Settings::from_toml(&toml).expect_err("should reject a repeated name"); + + assert!( + format!("{error:?}").contains("more than once"), + "should report the repeated name: {error:?}" + ); + } + + /// The tables removed from the configuration are refused with the move + /// spelled out, rather than with a bare unknown-field error. + #[test] + fn the_removed_integration_tables_are_refused_with_directions() { + for (table, expected) in [ + ("[integrations.example]", "CHANGELOG.md"), + ( + "[integration]\nmodules = [\"example\"]", + "is no longer read", ), - ) - .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 toml = format!("{}\n{table}\n", crate_test_settings_str()); + + let error = Settings::from_toml(&toml).expect_err("should reject the removed table"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("the section of its type") && rendered.contains(expected), + "should say where modules are selected now: {rendered}" + ); + } } + /// The same refusal reaches a configuration blob, which is the shape the + /// runtime loads rather than TOML. #[test] - fn disabled_removed_prebid_and_aps_fields_are_rejected() { - for (integration_id, removed_field) in [("prebid", "server_url"), ("aps", "account_id")] { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - integration_id, - &json!({ - "enabled": false, - (removed_field): "removed-value", - }), - ) - .expect("should insert removed integration config field"); - - let error = match integration_id { - "prebid" => settings - .integration_config::(integration_id) - .expect_err("should reject removed disabled Prebid field"), - "aps" => settings - .integration_config::(integration_id) - .expect_err("should reject removed disabled APS field"), - _ => unreachable!("test integration ID should be known"), - }; + fn json_settings_refuse_the_removed_integration_tables() { + for table in ["integrations", "integration"] { + let mut value = serde_json::to_value(create_test_settings()) + .expect("should serialize the test settings fixture to JSON"); + value + .as_object_mut() + .expect("settings should serialize as an object") + .insert(table.to_owned(), json!({ "example": {} })); + + let error = Settings::from_json_value(value) + .expect_err("should reject the removed table in JSON"); + assert!( - format!("{error:?}").contains(removed_field), - "should identify removed field `{removed_field}`: {error:?}" + format!("{error:?}").contains("the section of its type"), + "should say where modules are selected now: {error:?}" ); } } #[test] - fn enabled_invalid_integration_fails_registry_startup() { + fn invalid_settings_for_a_selected_module_fail_registry_startup() { + fn build( + settings: &Settings, + ) -> Result, Report> + { + Ok(settings + .module_config::(ENDPOINT_MODULE)? + .map(|_| crate::integrations::IntegrationRegistration::builder("endpoint").build())) + } + + fn validate(settings: &Settings) -> Result> { + settings + .module_config::(ENDPOINT_MODULE) + .map(|config| config.is_some()) + } + let mut settings = create_test_settings(); settings - .integrations - .insert_config( - "gpt", + .insert_module_config( + EXAMPLE_SECTION, + ENDPOINT_MODULE, &json!({ - "enabled": true, - "script_url": "not a url", + "endpoint": "not a url", }), ) - .expect("should insert GPT config"); + .expect("should insert the example module's table"); + let extra = [crate::integrations::IntegrationBuilder::new( + "endpoint", + "settings-tests", + build, + validate, + ) + .with_module_name(ENDPOINT_MODULE)]; - let err = match IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), - ) { - Ok(_) => panic!("enabled invalid integration should fail registry startup"), + let err = match IntegrationRegistry::with_registrations(&settings, &extra) { + Ok(_) => panic!("a selected module with invalid settings should fail startup"), Err(err) => err, }; assert!( - err.to_string().contains("Integration 'gpt'"), - "should identify the invalid integration config" + format!("{err:?}").contains("[example.endpoint]"), + "should identify the invalid module table: {err:?}" ); } @@ -6700,11 +8805,8 @@ source_domain = "partner.example.com" #[test] fn test_auction_creative_processing_defaults_when_omitted() { - let toml_str = crate_test_settings_str() - + r#" - [auction] - enabled = true - "#; + let toml_str = + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\n"); let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); @@ -6720,12 +8822,10 @@ source_domain = "partner.example.com" #[test] fn test_auction_rewrite_creatives_accepts_explicit_false() { - let toml_str = crate_test_settings_str() - + r#" - [auction] - enabled = true - rewrite_creatives = false - "#; + let toml_str = crate_test_settings_str().replace( + "[auction]\n", + "[auction]\nenabled = true\nrewrite_creatives = false\n", + ); let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); @@ -6746,12 +8846,7 @@ source_domain = "partner.example.com" #[test] fn test_auction_allowed_context_keys_from_toml() { - let toml_str = crate_test_settings_str() - + r#" - [auction] - enabled = true - allowed_context_keys = ["permutive_segments", "lockr_ids"] - "#; + let toml_str = crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = true\nallowed_context_keys = [\"permutive_segments\", \"lockr_ids\"]\n"); let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); assert_eq!( settings.auction.allowed_context_keys, @@ -6761,12 +8856,10 @@ source_domain = "partner.example.com" #[test] fn test_auction_empty_allowed_context_keys_blocks_all() { - let toml_str = crate_test_settings_str() - + r#" - [auction] - enabled = true - allowed_context_keys = [] - "#; + let toml_str = crate_test_settings_str().replace( + "[auction]\n", + "[auction]\nenabled = true\nallowed_context_keys = []\n", + ); let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); assert!( settings.auction.allowed_context_keys.is_empty(), @@ -6785,6 +8878,7 @@ source_domain = "partner.example.com" "*.Example.Org".to_string(), ], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert_eq!( @@ -6805,6 +8899,7 @@ source_domain = "partner.example.com" "cdn.example.com".to_string(), ], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert_eq!( @@ -6820,6 +8915,7 @@ source_domain = "partner.example.com" certificate_check: true, allowed_domains: vec!["*".to_string(), "tracker.com".to_string()], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert_eq!( @@ -6835,6 +8931,7 @@ source_domain = "partner.example.com" certificate_check: true, allowed_domains: vec!["*".to_string()], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert!( @@ -6849,6 +8946,7 @@ source_domain = "partner.example.com" certificate_check: true, allowed_domains: vec![" ".to_string(), "\t".to_string()], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert!( @@ -6867,6 +8965,7 @@ source_domain = "partner.example.com" origin_url: " https://assets.example.com ".to_string(), ..Default::default() }], + modules: SectionModules::default(), }; proxy.normalize(); assert_eq!( @@ -6891,6 +8990,7 @@ source_domain = "partner.example.com" target_path: Some(" /rewritten/$1 ".to_string()), ..Default::default() }], + modules: SectionModules::default(), }; proxy.normalize(); @@ -7615,6 +9715,7 @@ source_domain = "partner.example.com" ..Default::default() }, ], + modules: SectionModules::default(), }; let route = proxy @@ -7643,6 +9744,7 @@ source_domain = "partner.example.com" ..Default::default() }, ], + modules: SectionModules::default(), }; let route = proxy @@ -7873,8 +9975,14 @@ source_domain = "partner.example.com" proxy_secret = "unit-test-proxy-secret" [ec] + module = "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" @@ -8207,7 +10315,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +module = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -8291,7 +10405,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +module = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -8327,7 +10447,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +module = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -8369,7 +10495,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +module = "hmac" + +[ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -8470,6 +10602,29 @@ formats = [{{ width = 300, height = 250 }}] ); } + /// An unset selector must not serialize as `"module": 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_module_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("module"), + "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("module"), + "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"); @@ -8512,3 +10667,199 @@ 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 module, 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_module() { + let config = PermissionSignalConfig::default(); + assert!( + config.modules.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#"modules = ["gpc", "tcf"]"#).expect("should parse the section"); + assert_eq!( + parsed.modules.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 { + modules: 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!({ "modules": 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("modules = []").expect("should parse an empty list"); + assert_eq!( + parsed.modules.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#"module_list = ["gpc"]"#) + .expect_err("should refuse a misspelled key rather than silently ignore it"); + assert!( + error + .to_string() + .contains("unknown field `module_list` in [permission-signal], expected `modules`"), + "the refusal names the key it did not recognize and the one it accepts: {error}" + ); + } + + #[test] + fn the_singular_key_is_refused_naming_modules() { + let error = toml::from_str::(r#"module = ["gpc"]"#) + .expect_err("should refuse the singular key the other sections use"); + assert!( + error + .to_string() + .contains("[permission-signal] module is `modules` here"), + "the refusal says which key to write instead: {error}" + ); + } + + #[test] + fn the_removed_sources_key_is_refused_naming_modules() { + for written in [ + r#"sources = ["gpc", "tcf"]"#, + "modules = [\"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 modules \ + to run, in order, in [permission-signal] modules 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] modules"), + "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] modules"), + "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] modules"), + "startup names the key that replaced it: {error:?}" + ); + } + + #[test] + fn the_old_table_name_is_refused_with_its_new_name() { + let written = format!( + "{}\n[permission_signal]\nmodules = [\"gpc\"]\n", + crate_test_settings_str() + ); + let error = Settings::from_toml(&written).expect_err("should refuse the old table name"); + let message = format!("{error:?}"); + assert!( + message.contains("[permission_signal]") && message.contains("[permission-signal]"), + "the refusal names the old table and the new one: {message}" + ); + } + + #[test] + fn the_old_ad_server_table_name_is_refused_with_its_new_name() { + let written = format!( + "{}\n[adserver]\nmodule = \"adserver_mock\"\n", + crate_test_settings_str() + ); + let error = Settings::from_toml(&written).expect_err("should refuse the old table name"); + let message = format!("{error:?}"); + assert!( + message.contains("[adserver]") && message.contains("[ad-server] module"), + "the refusal names the old table and the new selector: {message}" + ); + } + + #[test] + fn a_block_of_module_settings_is_refused_as_an_unknown_field() { + // No module takes settings yet, so a block for one is refused rather + // than read and then ignored. + let written = + settings_toml_with("modules = [\"gpc\"]\n\n[permission-signal.gpc]\nenabled = true"); + let error = + Settings::from_toml(&written).expect_err("should refuse settings no module 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/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 07108fac64..b753f658f7 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -4,8 +4,9 @@ use serde::Deserialize; use sha2::{Digest as _, Sha256}; pub use crate::config_payload::DEFAULT_CONFIG_STORE_ID; -use crate::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; +use crate::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob_with}; use crate::error::TrustedServerError; +use crate::integrations::IntegrationBuilder; use crate::platform::{PlatformConfigStore, PlatformSecretStore, StoreName}; use crate::settings::Settings; @@ -68,7 +69,13 @@ pub fn default_secret_store_name() -> StoreName { StoreName::from(EnvConfig::from_env().store_name("secrets", DEFAULT_SECRET_STORE_ID)) } -/// Loads [`Settings`] from a platform config store and key. +/// Loads [`Settings`] from a platform config store and key, validating +/// against the built-in integrations only. +/// +/// A deployment that composes builders of its own calls +/// [`get_settings_from_config_store_with`] instead, because validation runs +/// during the load and refuses a `[demand]` or `[ad-server]` name it cannot +/// see a builder for. /// /// # Errors /// @@ -81,10 +88,41 @@ pub fn get_settings_from_config_store( store_name: &StoreName, key: &str, default_secret_store_name: &StoreName, +) -> Result> { + get_settings_from_config_store_with( + config_store, + secret_store, + store_name, + key, + default_secret_store_name, + &[], + ) +} + +/// Loads [`Settings`] from a platform config store and key, validating +/// against the built-in integrations followed by `extra_integrations`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the config blob is +/// missing, cannot be read, fails envelope verification, secret resolution, +/// or Trusted Server settings validation. +pub fn get_settings_from_config_store_with( + config_store: &dyn PlatformConfigStore, + secret_store: &dyn PlatformSecretStore, + store_name: &StoreName, + key: &str, + default_secret_store_name: &StoreName, + extra_integrations: &[IntegrationBuilder], ) -> Result> { let raw_value = read_config_entry(config_store, store_name, key)?; let envelope_json = resolve_fastly_chunk_pointer(config_store, store_name, &raw_value)?; - settings_from_config_blob(&envelope_json, secret_store, default_secret_store_name) + settings_from_config_blob_with( + &envelope_json, + secret_store, + default_secret_store_name, + extra_integrations, + ) } fn read_config_entry( diff --git a/crates/trusted-server-core/src/tdl.rs b/crates/trusted-server-core/src/tdl.rs new file mode 100644 index 0000000000..8adcddb65d --- /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 +//! [`PermissionSignalModule`](crate::permission_signal::PermissionSignalModule) +//! declares for the request and makes them visible to what consumes the data, +//! and the module 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 module. +//! +//! # 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 37b8a9e657..6589b214fb 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -1,8 +1,10 @@ -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] pub mod tests { use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource, TcfConsent}; - use crate::settings::Settings; + use crate::ec::module::{EcModuleSelection, HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_KEY}; + use crate::redacted::Redacted; + use crate::settings::{Ec, EcModuleBlock, HmacModuleConfig, HostSignalsModuleConfig, Settings}; #[must_use] pub fn crate_test_settings_str() -> String { @@ -23,19 +25,22 @@ pub mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" - [integrations.prebid] - enabled = true - external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" + [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 module, so single-jurisdiction operation + # is acknowledged the same way a deployment would. + assume_single_jurisdiction = true - [integrations.prebid.bundle.modules] - bidder = ["exampleBidderBidAdapter"] - - [integrations.nextjs] - enabled = false - rewrite_attributes = ["href", "link", "url"] + [auction] [ec] + module = "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" @@ -43,6 +48,44 @@ pub mod tests { .to_owned() } + /// The shared fixture TOML with the module `name` selected in `section` + /// as well, written with the section's type folder left off, for a test + /// that appends that module's own table. + #[must_use] + pub fn crate_test_settings_str_running(section: &str, name: &str) -> String { + let written = crate::module_name::short_form(section, name); + if section == "auction" { + return crate_test_settings_str().replace( + "[auction]\n", + &format!("[auction]\nmodules = [\"{written}\"]\n"), + ); + } + format!( + "{}\n[{section}]\nmodules = [\"{written}\"]\n", + crate_test_settings_str() + ) + } + + /// The crate test configuration with its whole `[ec]` section replaced by + /// `ec_section`, which carries its own `[ec]` header and any module + /// 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. /// @@ -56,10 +99,73 @@ pub mod tests { settings } + /// Selects the built-in HMAC module under `name` with `passphrase`, + /// replacing whatever Edge Cookie module the settings carried. + /// + /// A `name` other than `hmac` is a label, so the block names the + /// implementation it configures. + pub fn select_hmac_module(ec: &mut Ec, name: &str, passphrase: &str) { + let mut block = EcModuleBlock::from(HmacModuleConfig { + passphrase: Redacted::new(passphrase.to_owned()), + }); + if name != HMAC_MODULE_KEY { + block.implementation = Some(HMAC_MODULE_KEY.to_owned()); + } + ec.module = Some(EcModuleSelection::from(name)); + ec.module_blocks.clear(); + ec.module_blocks.insert(name.to_owned(), block); + } + + /// Selects the built-in host-signal module under its own name with + /// `passphrase`, replacing whatever Edge Cookie module the settings + /// carried. + pub fn select_host_signals_module(ec: &mut Ec, passphrase: &str) { + ec.module = Some(EcModuleSelection::from(HOST_SIGNALS_MODULE_KEY)); + ec.module_blocks.clear(); + ec.module_blocks.insert( + HOST_SIGNALS_MODULE_KEY.to_owned(), + EcModuleBlock::from(HostSignalsModuleConfig { + 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 + /// module. + #[must_use] + #[allow( + clippy::panic, + reason = "a fixture names the block it could not find, which `expect` cannot" + )] + pub fn hmac_passphrase<'a>(ec: &'a Ec, name: &str) -> &'a str { + ec.module_blocks + .get(name) + .and_then(EcModuleBlock::hmac_settings) + .unwrap_or_else(|| panic!("settings should configure the hmac module 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"; + /// The permissions of a reader who allowed storage and withheld + /// personalised-ad selection: the Edge Cookie stands, partner sharing does + /// not. + #[must_use] + pub fn storage_only_permissions() -> crate::permissions::PermissionState { + crate::permissions::PermissionState::new( + [crate::permissions::Permission::StoreOnDevice] + .into_iter() + .collect(), + ) + } + /// GDPR consent granting TCF Purpose 1 (storage/EC) but denying Purpose 4 /// (personalized ads), so EC is allowed while EID persistence is not. #[must_use] @@ -94,6 +200,75 @@ pub mod tests { } } +/// Page fixtures shared by core's tests and the tests of a module crate. +#[cfg(any(test, feature = "test-utils"))] +pub mod fixtures { + /// A publisher page carrying the tags of several vendors, as an origin + /// would serve it. + pub const PUBLISHER_PAGE_HTML: &str = include_str!("html_processor.test.html"); +} + +/// The operator-facing settings template, for tests that check what it +/// documents. +#[cfg(any(test, feature = "test-utils"))] +pub mod template { + /// The source-controlled template an operator starts from. + pub const EXAMPLE_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../trusted-server.example.toml" + )); + + /// The template with its required secret-store key references replaced by + /// resolved test values, so parsing it as settings can exercise the + /// optional blocks a test uncomments. + #[must_use] + pub fn template_with_resolved_required_secrets() -> String { + EXAMPLE_TEMPLATE + .replace( + "password = \"handler_password\"", + "password = \"unit-test-resolved-handler-password-0001\"", + ) + .replace( + "proxy_secret = \"publisher_proxy_secret\"", + "proxy_secret = \"unit-test-resolved-publisher-proxy-secret-0001\"", + ) + .replace( + "passphrase = \"ec_passphrase\"", + "passphrase = \"unit-test-resolved-ec-passphrase-secret-0001\"", + ) + } + + /// Uncomments the contiguous `#`-prefixed block that begins at the line + /// `# {header}`, leaving the rest of the template untouched. Stops at the + /// first line that is not a comment, so a blank line ends the block. + #[must_use] + pub fn uncomment_block(template: &str, header: &str) -> String { + let header_line = format!("# {header}"); + let mut out = Vec::new(); + let mut uncommenting = false; + + for line in template.lines() { + if line == header_line { + uncommenting = true; + } else if uncommenting && !line.trim_start().starts_with('#') { + uncommenting = false; + } + + if uncommenting { + let bare = line + .strip_prefix("# ") + .or_else(|| line.strip_prefix('#')) + .unwrap_or(line); + out.push(bare.to_owned()); + } else { + out.push(line.to_owned()); + } + } + + out.join("\n") + } +} + /// Shared Next.js + auction origin fixture. /// /// Adapters exercise the buffered publisher path against this fixture in their @@ -145,30 +320,47 @@ pub mod nextjs_auction { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + # The fixture supplies its own geo module 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] + module = "platform" "#, ) .expect("should parse Next.js auction fixture settings"); settings - .integrations - .insert_config( - "nextjs", + .insert_module_config( + "framework", + "framework.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. `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!("auction-protocol.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#" @@ -312,10 +504,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 e8275b8144..8cb2216a2e 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 0000000000..4e98e491c8 --- /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.to_vec(), 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/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index 39f985860a..c83377fca4 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -25,6 +25,7 @@ edgezero-core = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } trusted-server-core = { workspace = true, features = ["test-utils"] } +trusted-server-modules = { workspace = true } [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/trusted-server-integration-tests/browser/initial-render/run.cjs b/crates/trusted-server-integration-tests/browser/initial-render/run.cjs index 681ba584a1..d69652bda6 100644 --- a/crates/trusted-server-integration-tests/browser/initial-render/run.cjs +++ b/crates/trusted-server-integration-tests/browser/initial-render/run.cjs @@ -67,7 +67,7 @@ const scriptContents = new Map( [ path.join( repo, - "crates/trusted-server-core/src/integrations/gpt_bootstrap.js", + "crates/ad-tag/google/src/gpt_bootstrap.js", ), path.join(dist, "tsjs-core.js"), path.join(dist, "tsjs-gpt.js"), @@ -163,7 +163,7 @@ const scenarios = [ scripts.push( path.join( repo, - "crates/trusted-server-core/src/integrations/gpt_bootstrap.js", + "crates/ad-tag/google/src/gpt_bootstrap.js", ), ); scripts.push( @@ -474,7 +474,7 @@ const scenarios = [ files: [ path.join( repo, - "crates/trusted-server-core/src/integrations/gpt_bootstrap.js", + "crates/ad-tag/google/src/gpt_bootstrap.js", ), path.join(dist, "tsjs-core.js"), path.join(dist, "tsjs-gpt.js"), 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 c3d9ff53d8..a5c3613bf7 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,20 @@ cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" proxy_secret = "integration_proxy_secret" +[geo] +# Viceroy maps the loopback client to US/CA (see viceroy-template.toml), so +# the platform geo module resolves a real place and the EC lifecycle +# scenarios exercise the US state opt-out machinery. +module = "platform" + [ec] -passphrase = "integration_ec_passphrase" +module = "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 +40,11 @@ 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. +# A settings table its section does not select refuses startup, so no other +# integration has a table here. +[ad-tag] +modules = ["google.diagnostics"] [proxy] certificate_check = false @@ -101,47 +54,31 @@ enabled = false timeout_ms = 2000 allowed_context_keys = [] -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +modules = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "auction.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 = "auction.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" +module = "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 eae7220a84..374e95e426 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,52 @@ [local_server] + # Maps the loopback client to California, United States, so the platform + # geo module resolves a real place and the permission model applies the + # US/CA rules from the permissions.yaml tree. + [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/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index aada5dcf54..4388a47891 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs @@ -121,6 +121,9 @@ fn build_app_config_envelope( if let Some(origin_url) = origin_url { settings.publisher.origin_url = origin_url.to_string(); } + // The fixture selects modules a stock build ships, so validation needs + // the stock list as the adapters and the `ts` tool have it. + trusted_server_core::config::register_deploy_integrations(trusted_server_modules::builders()); let app_config = TrustedServerAppConfig::new(settings) .map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?; @@ -165,7 +168,7 @@ mod tests { use super::*; use error_stack::Report; use std::collections::HashMap; - use trusted_server_core::config_payload::settings_from_config_blob; + use trusted_server_core::config_payload::settings_from_config_blob_with; use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; const TEMPLATE: &str = include_str!("../../fixtures/configs/viceroy-template.toml"); @@ -317,10 +320,11 @@ mod tests { fn generated_blob_verifies_and_applies_origin_override() { let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999")) .expect("should build envelope"); - let settings = settings_from_config_blob( + let settings = settings_from_config_blob_with( &envelope, &integration_secret_store(), &StoreName::from("trusted_server_secrets"), + &trusted_server_modules::builders(), ) .expect("should verify blob"); diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 0b793833f5..ff19b4f8de 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -33,6 +33,9 @@ fn app_config_envelope( })?; let mut settings = app_config.into_settings(); settings.publisher.origin_url = origin_url; + // The fixture selects modules a stock build ships, so validation needs + // the stock list as the adapters and the `ts` tool have it. + trusted_server_core::config::register_deploy_integrations(trusted_server_modules::builders()); let app_config = TrustedServerAppConfig::new(settings).map_err(|report| { Report::new(TestError::ConfigGeneration) .attach(format!("invalid generated integration config: {report:?}")) @@ -57,14 +60,18 @@ pub fn cloudflare_config_json(origin_port: u16) -> TestResult { /// Builds a Cloudflare config binding with server-side auctions enabled. /// -/// The fixture's providers are removed so the enabled auction completes -/// through the orchestrator and telemetry without any outbound provider call. +/// The fixture's demand modules and bidder routes are removed so the enabled +/// auction completes through the orchestrator and telemetry without any +/// outbound call, and so Cloudflare, which runs one demand source at a time, +/// accepts it at startup. pub fn cloudflare_auction_config_json(origin_port: u16) -> TestResult { let envelope = app_config_envelope(origin_port, |table| { let auction = table_mut(table, "auction")?; auction.insert("enabled".to_string(), toml::Value::Boolean(true)); - auction.remove("providers"); auction.remove("bidders"); + let mut demand = toml::Table::new(); + demand.insert("modules".to_string(), toml::Value::Array(Vec::new())); + table.insert("demand".to_string(), toml::Value::Table(demand)); Ok(()) })?; cloudflare_binding_json(&serde_json::json!({ CONFIG_BLOB_KEY: envelope })) diff --git a/crates/trusted-server-integration-tests/tests/documentation_snippets.rs b/crates/trusted-server-integration-tests/tests/documentation_snippets.rs index d9c36d2338..0e56dbc119 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 c1bbafe8df..a7a45f2f63 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 2428d7799b..d825c9608a 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] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse parity test settings") @@ -434,6 +440,206 @@ async fn discovery_route_body_is_json_parity() { } } +/// The configuration view is built by core from the settings alone, so +/// every adapter shows the same one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_endpoint_parity() { + use http_body_util::BodyExt as _; + use serde_json::Value; + + const PATH: &str = "/_ts/config.json"; + + let (axum_status, axum_body) = { + let mut svc = EdgeZeroAxumService::new(axum_router()); + let req = AxumRequest::builder() + .method("GET") + .uri(PATH) + .body(AxumBody::empty()) + .expect("should build GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + let body = resp + .into_body() + .collect() + .await + .expect("should collect body") + .to_bytes(); + (status, body) + }; + let (cf_status, cf_body) = { + let req = request_builder() + .method("GET") + .uri(PATH) + .body(edgezero_core::body::Body::empty()) + .expect("should build GET request"); + let resp = cf_router().oneshot(req).await.expect("should respond"); + let status = resp.status().as_u16(); + (status, resp.into_body().into_bytes().unwrap_or_default()) + }; + let (spin_status, _, spin_body) = spin_get_body(PATH).await; + + assert_eq!(axum_status, 200, "Axum should answer {PATH}"); + assert_eq!(cf_status, 200, "Cloudflare should answer {PATH}"); + assert_eq!(spin_status, 200, "Spin should answer {PATH}"); + + let axum: Value = serde_json::from_slice(&axum_body).expect("Axum should answer JSON"); + let cf: Value = serde_json::from_slice(&cf_body).expect("Cloudflare should answer JSON"); + let spin: Value = serde_json::from_slice(&spin_body).expect("Spin should answer JSON"); + assert_eq!(axum, cf, "{PATH} must match across adapters (axum, cf)"); + assert_eq!(cf, spin, "{PATH} must match across adapters (cf, spin)"); + assert_eq!( + axum["settings"]["publisher"]["proxy_secret"], "XXXX", + "a secret should be masked" + ); + assert!( + !String::from_utf8_lossy(&axum_body).contains("parity-test-proxy-secret"), + "the view should not carry the secret anywhere" + ); +} + +/// Whether a request is a browser opening the page is core's decision, and +/// none of these three adapters keeps an identity graph, so each refuses and +/// answers alike. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn data_endpoint_parity() { + use http_body_util::BodyExt as _; + + const PATH: &str = "/_ts/data"; + const OPENED_AS_A_PAGE: [(&str, &str); 2] = [ + ("sec-fetch-mode", "navigate"), + ("sec-fetch-dest", "document"), + ]; + + async fn edge( + router: RouterService, + headers: &[(&str, &str)], + ) -> (u16, Option, bytes::Bytes) { + let mut builder = request_builder().method("GET").uri(PATH); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build GET request"); + let resp = router.oneshot(req).await.expect("should respond"); + ( + resp.status().as_u16(), + resp.headers().get("content-security-policy").cloned(), + resp.into_body().into_bytes().unwrap_or_default(), + ) + } + + for (headers, expected) in [(&[][..], 403), (&OPENED_AS_A_PAGE[..], 200)] { + let axum = { + let mut svc = EdgeZeroAxumService::new(axum_router()); + let mut builder = AxumRequest::builder().method("GET").uri(PATH); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let req = builder + .body(AxumBody::empty()) + .expect("should build GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + let policy = resp.headers().get("content-security-policy").cloned(); + let body = resp + .into_body() + .collect() + .await + .expect("should collect body") + .to_bytes(); + (status, policy, body) + }; + let cf = edge(cf_router(), headers).await; + let spin = edge(spin_router(), headers).await; + + assert_eq!( + axum.0, expected, + "Axum should answer {PATH} with {expected}" + ); + assert_eq!(axum, cf, "{PATH} must match across adapters (axum, cf)"); + assert_eq!(cf, spin, "{PATH} must match across adapters (cf, spin)"); + assert!( + axum.1 + .as_ref() + .and_then(|policy| policy.to_str().ok()) + .is_some_and(|policy| policy.starts_with("sandbox;")), + "every answer should be in an origin of its own" + ); + } +} + +/// The permissions a request resolves to are core's decision, so the +/// endpoint that shows them answers the same on every adapter. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_endpoint_parity() { + use http_body_util::BodyExt as _; + use serde_json::Value; + + const PATH: &str = "/_ts/permissions.json"; + + let (axum_status, axum_body) = { + let mut svc = EdgeZeroAxumService::new(axum_router()); + let req = AxumRequest::builder() + .method("GET") + .uri(PATH) + .body(AxumBody::empty()) + .expect("should build GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + let body = resp + .into_body() + .collect() + .await + .expect("should collect body") + .to_bytes(); + (status, body) + }; + let (cf_status, cf_body) = { + let req = request_builder() + .method("GET") + .uri(PATH) + .body(edgezero_core::body::Body::empty()) + .expect("should build GET request"); + let resp = cf_router().oneshot(req).await.expect("should respond"); + let status = resp.status().as_u16(); + (status, resp.into_body().into_bytes().unwrap_or_default()) + }; + let (spin_status, _, spin_body) = spin_get_body(PATH).await; + + assert_eq!(axum_status, 200, "Axum should answer {PATH}"); + assert_eq!(cf_status, 200, "Cloudflare should answer {PATH}"); + assert_eq!(spin_status, 200, "Spin should answer {PATH}"); + + let axum: Value = serde_json::from_slice(&axum_body).expect("Axum should answer JSON"); + let cf: Value = serde_json::from_slice(&cf_body).expect("Cloudflare should answer JSON"); + let spin: Value = serde_json::from_slice(&spin_body).expect("Spin should answer JSON"); + assert_eq!(axum, cf, "{PATH} must match across adapters (axum, cf)"); + assert_eq!(cf, spin, "{PATH} must match across adapters (cf, spin)"); + assert!( + axum["set"].is_array() && axum["awaiting"].is_array(), + "the resolution should say what is set and what is awaited" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn verify_signature_route_parity() { // known divergence: without real signing-key configuration the handler may diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 24f842e764..2a48d7071b 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 auction.prebid.external_bundle_url. * * The Rust build script passes `--out-dir` with a private directory under * Cargo's OUT_DIR, so concurrent cargo builds never share (or clean) the same diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index a02c5bf60a..7cce1f103b 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 auction.prebid.external_bundle_url. */ import crypto from 'node:crypto'; @@ -43,19 +43,19 @@ const MODULE_KIND_DEFINITIONS = Object.freeze([ { requestKey: 'bidder', tomlKey: 'bidder', - fieldPath: 'integrations.prebid.bundle.modules.bidder', + fieldPath: 'auction.prebid.bundle.modules.bidder', metadataType: 'bidder', }, { requestKey: 'userId', tomlKey: 'user_id', - fieldPath: 'integrations.prebid.bundle.modules.user_id', + fieldPath: 'auction.prebid.bundle.modules.user_id', metadataType: 'userId', }, { requestKey: 'analytics', tomlKey: 'analytics', - fieldPath: 'integrations.prebid.bundle.modules.analytics', + fieldPath: 'auction.prebid.bundle.modules.analytics', metadataType: 'analytics', }, ]); diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index b2c4e41e15..2678a879f1 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 0000000000..6f037b0215 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/permissions.ts @@ -0,0 +1,89 @@ +// 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 the four lists without checking any exists, so each is an +// array whatever arrived. The edge always sends all four, 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 : [], + awaiting: Array.isArray(snapshot.awaiting) ? snapshot.awaiting : [], + signals: Array.isArray(snapshot.signals) ? snapshot.signals : [], + 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: [], awaiting: [], signals: [], 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: [], awaiting: [], signals: [], 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 3407d487a0..742e8355af 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -365,6 +365,36 @@ 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 modules 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[]; + /** + * Permissions still waiting for a signal. Not set, and not refused + * either, which page code must tell apart from `set` being empty. + */ + awaiting: string[]; + /** + * The signals the server's modules read and found valid, each as it + * was received. A signal absent from this list was missing, could not be + * read, or is one the deployment does not act on, and page code relies + * on exactly these and no other. + */ + signals: ValidSignal[]; + tdls: string[]; +} + export type FirstImpressionOwner = 'publisher' | 'trusted_server'; export type FirstImpressionPhase = 'auctioning' | 'delivery_pending' | 'requested' | 'rendered'; @@ -403,6 +433,16 @@ export interface FirstImpressionState { fallbackSlots: Record; } +/** One signal a server-side module read and found valid. */ +export interface ValidSignal { + /** The module that read it, by its configured id. */ + module: string; + /** The scheme it belongs to, for example `tcf` or `gpp`. */ + scheme: string; + /** The signal as received. */ + value: string; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -430,6 +470,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 0000000000..e7af850bc3 --- /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 module (`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` +// module 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 module 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/module.rs; a Rust test asserts the two +// stay in sync. +const FIXED_WORD = 'an-ec'; + +// The permission this module requires, the same declaration the server-side +// module makes in `required_permissions` (crates/trusted-server-core/src/ec/ +// module.rs). A page module is treated like any other module: 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/google_tag_manager/script_guard.ts b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts index 2072c04032..874748bec8 100644 --- a/crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts @@ -21,7 +21,7 @@ const GTM_URL_PATTERN = /** * Supported paths that the server can proxy. * Must match the route patterns defined in the GoogleTagManagerIntegration handler - * in crates/trusted-server-core/src/integrations/google_tag_manager.rs + * in crates/tag/google-tag-manager/src/lib.rs */ const SUPPORTED_PATHS = ['/gtm.js', '/gtag/js', '/gtag.js', '/collect', '/g/collect']; 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 82c0e8c6b7..84fa14e4cc 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 `auction.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 @@ -1988,7 +1988,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 + // (auction.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()) { @@ -2609,7 +2609,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 ' + + '[auction.prebid.bundle.modules].bidder in trusted-server.toml and ' + 'rebuild it with `ts prebid client`.' ); } diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts index 3e1493bdbb..dd4624aa77 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts @@ -12,7 +12,7 @@ function normalizeSourcepointUrl(url: string): string | null { if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; // Keep in sync with Rust `parse_sourcepoint_url()` in: - // crates/trusted-server-core/src/integrations/sourcepoint.rs + // crates/cmp/sourcepoint/src/lib.rs // (protocol-relative + bare-host handling + hostname-only host checks). // Bare domain or path — attempt to parse as https URL. // The hostname === check in isSourcepointUrl rejects non-matching domains. diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 7cff739ef5..20fe29c67e 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -134,7 +134,7 @@ describe('build-prebid-external request parsing', () => { 'ats\nAnalyticsAdapter', ])('rejects invalid analytics stem %j', (stem) => { expect(() => parseRequest(completeRequest({ analytics: [stem] }))).toThrow( - 'integrations.prebid.bundle.modules.analytics' + 'auction.prebid.bundle.modules.analytics' ); }); @@ -146,7 +146,7 @@ describe('build-prebid-external request parsing', () => { it('rejects a stem repeated across kinds', () => { expect(() => parseRequest({ bidder: ['exampleModule'], analytics: ['exampleModule'] })).toThrow( - 'already selected by integrations.prebid.bundle.modules.bidder' + 'already selected by auction.prebid.bundle.modules.bidder' ); }); @@ -413,7 +413,7 @@ describe('build-prebid-external module resolution', () => { resolveSpecifier: () => fixture.target, }) ) - ).toThrow('Prebid metadata for integrations.prebid.bundle.modules.analytics'); + ).toThrow('Prebid metadata for auction.prebid.bundle.modules.analytics'); } finally { fs.rmSync(fixture.temp, { recursive: true, force: true }); } @@ -667,7 +667,7 @@ describe('build-prebid-external rendering and orchestration', () => { error = String(cause); } expect(error).toContain( - 'integrations.prebid.bundle.modules.analytics requested "mavenDistributionAnalyticsAdapter"' + 'auction.prebid.bundle.modules.analytics requested "mavenDistributionAnalyticsAdapter"' ); expect(error).toContain(`prebid.js ${prebidVersion}`); expect(error).toContain('modules/mavenDistributionAnalyticsAdapter.js'); 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 0000000000..a7c0bf5e6f --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/permissions.test.ts @@ -0,0 +1,146 @@ +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'], + awaiting: [], + signals: [], + 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'], + awaiting: [], + signals: [], + tdls: [], + }; + + await expect(settled).resolves.toEqual({ + set: ['marketing.advertising.serving'], + awaiting: [], + signals: [], + tdls: [], + }); + expect(api.permissions).toEqual({ + set: ['marketing.advertising.serving'], + awaiting: [], + signals: [], + 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: [], awaiting: [], signals: [], 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: [], + awaiting: [], + signals: [], + 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'], awaiting: [], signals: [], tdls: [] }; + const first = await api.whenPermissions!(); + const second = await api.whenPermissions!(); + + expect(second).toBe(first); + expect(second).toEqual({ set: ['analytics.reporting'], awaiting: [], signals: [], 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'], + awaiting: [], + signals: [], + tdls: [], + }); + }); + + it('carries the signals the edge found valid, as received', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { + set: ['necessary.operations.storage'], + awaiting: [], + signals: [{ module: 'tcf', scheme: 'tcf', value: 'CPxyz' }], + tdls: [], + }; + + expect(api.permissions.signals).toEqual([{ module: 'tcf', scheme: 'tcf', value: 'CPxyz' }]); + }); + + 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'], + awaiting: [], + signals: [], + 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/fixtures/paths.ts b/crates/trusted-server-js/lib/test/fixtures/paths.ts index 199b105c11..90c34dc575 100644 --- a/crates/trusted-server-js/lib/test/fixtures/paths.ts +++ b/crates/trusted-server-js/lib/test/fixtures/paths.ts @@ -13,5 +13,5 @@ import { resolve } from 'node:path'; */ export const GPT_BOOTSTRAP_PATH = resolve( import.meta.dirname, - '../../../../trusted-server-core/src/integrations/gpt_bootstrap.js' + '../../../../ad-tag/google/src/gpt_bootstrap.js' ); 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 0000000000..97f59bebfb --- /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/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 46ba778dc9..1f8df3a3a6 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -10,7 +10,7 @@ import { GPT_BOOTSTRAP_PATH } from '../../fixtures/paths'; * Executable coverage for the edge-injected `gpt_bootstrap.js` — the * head-inline fallback that keeps initial server-side ads working when the * main TSJS bundle fails to load. The file ships from - * `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` and is + * `crates/ad-tag/google/src/gpt_bootstrap.js` and is * evaluated here verbatim, so the degradation path (fallback `adInit` and * fallback `scheduleInitialAdInit`) is executed, not string-matched. */ 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 0c209b89a1..4d66b0f571 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 @@ -7755,7 +7755,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('[auction.prebid.bundle.modules].bidder') ) ); expect(pointsAtBundleConfig).toBe(true); diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml new file mode 100644 index 0000000000..161d26974f --- /dev/null +++ b/crates/trusted-server-modules/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "trusted-server-modules" +description = "The modules a stock build of Trusted Server ships from crates of their own, in the order their hooks run." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-ad-server-mock = { workspace = true } +trusted-server-ad-tag-google = { workspace = true } +trusted-server-auction-aps = { workspace = true } +trusted-server-auction-prebid = { workspace = true } +trusted-server-auction-prebid-server = { workspace = true } +trusted-server-auction-protocol-openrtb = { workspace = true } +trusted-server-audience-permutive = { workspace = true } +trusted-server-bot-protection-datadome = { workspace = true } +trusted-server-cmp-didomi = { workspace = true } +trusted-server-cmp-osano = { workspace = true } +trusted-server-cmp-sourcepoint = { workspace = true } +trusted-server-core = { workspace = true } +trusted-server-framework-nextjs = { workspace = true } +trusted-server-identity-lockr = { workspace = true } +trusted-server-tag-google-tag-manager = { workspace = true } +trusted-server-testing-testlight = { workspace = true } + +[dev-dependencies] +edgezero-core = { workspace = true } +http = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. Which modules a stock build ships is the +# project's own decision, so the Trusted Server maintainers own it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "project owned" diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs new file mode 100644 index 0000000000..2951f12b33 --- /dev/null +++ b/crates/trusted-server-modules/src/lib.rs @@ -0,0 +1,557 @@ +//! The modules a stock build of Trusted Server ships from crates of their own. +//! +//! Every adapter and the `ts` command line tool take this list, so one place +//! says which of those modules a stock build offers and in what order their +//! hooks run. Offering a module does not run it, because the registry builds a +//! module only when a section of the settings selects it. +//! +//! A deployment that ships a module of its own hands that module's builder to +//! its adapter, which runs it after these. + +#![cfg_attr( + test, + allow(clippy::panic, reason = "tests use panic-on-failure helpers") +)] + +use trusted_server_core::integrations::IntegrationBuilder; + +/// The builders of the modules a stock build ships from crates of their own, +/// in hook order. +#[must_use] +pub fn builders() -> Vec { + vec![ + // Prebid registers from the auction plan, and what it registers runs + // ahead of the modules below. + trusted_server_auction_prebid::builder(), + trusted_server_testing_testlight::builder(), + trusted_server_framework_nextjs::builder(), + trusted_server_audience_permutive::builder(), + trusted_server_identity_lockr::builder(), + trusted_server_cmp_didomi::builder(), + trusted_server_cmp_sourcepoint::builder(), + trusted_server_cmp_osano::builder(), + trusted_server_tag_google_tag_manager::builder(), + trusted_server_bot_protection_datadome::builder(), + trusted_server_ad_tag_google::builder(), + trusted_server_ad_tag_google::diagnostics::builder(), + // Implementations `[demand]` and `[ad-server]` can name, which no + // section selects. + trusted_server_auction_protocol_openrtb::builder(), + trusted_server_auction_prebid_server::builder(), + trusted_server_auction_aps::builder(), + trusted_server_ad_server_mock::builder(), + ] +} + +/// The stock builders followed by `extra`, the builders a deployment added, +/// which is the order their hooks run in. +#[must_use] +pub fn builders_with(extra: &[IntegrationBuilder]) -> Vec { + let mut all = builders(); + all.extend_from_slice(extra); + all +} + +/// The section that selects the stock module with the integration id `id`, +/// and the name the module is written under there. +/// +/// A tool that knows an integration by its id, as the `ts` audit does from +/// what it finds on a page, writes a configuration with this and names no +/// module itself. +#[must_use] +pub fn selection_of(id: &str) -> Option<(&'static str, &'static str)> { + let builder = builders().into_iter().find(|builder| builder.id() == id)?; + let section = builder.section()?; + Some(( + section, + trusted_server_core::module_name::short_form(section, builder.module_name()?), + )) +} + +#[cfg(test)] +mod tests { + use trusted_server_core::integrations::IntegrationBuilder; + + use super::{builders, builders_with, selection_of}; + + #[test] + fn stock_modules_are_offered_in_hook_order() { + // A module is named by what its section selects, and an + // implementation by what `[demand]` or `[ad-server]` names. + let names: Vec<&str> = builders() + .iter() + .map(|builder| { + builder + .module_name() + .or_else(|| builder.demand().map(|demand| demand.id)) + .or_else(|| builder.adserver().map(|adserver| adserver.id)) + .unwrap_or_else(|| builder.id()) + }) + .collect(); + + assert_eq!( + names, + [ + "auction.prebid", + "testing.testlight", + "framework.nextjs", + "audience.permutive", + "identity.lockr", + "cmp.didomi", + "cmp.sourcepoint", + "cmp.osano", + "tag.google-tag-manager", + "bot-protection.datadome", + "ad-tag.google", + "ad-tag.google.diagnostics", + "auction-protocol.openrtb", + "auction.prebid-server", + "auction.aps", + "ad-server.mock", + ], + "should offer the stock modules in the order their hooks run" + ); + } + + #[test] + fn a_deployment_s_builders_follow_the_stock_ones() { + let stock = builders().len(); + let extra = [trusted_server_cmp_osano::builder()]; + + let all = builders_with(&extra); + + assert_eq!( + all.len(), + stock + 1, + "should keep every stock builder and add the deployment's" + ); + assert_eq!( + all.last().map(IntegrationBuilder::source), + Some("trusted-server-cmp-osano"), + "should put the deployment's builder last" + ); + } + + /// The `ts` tool registers the stock builders, so validating a deployment's + /// settings before a push runs each stock module's own rules. + #[test] + fn deploy_validation_runs_a_stock_module_s_own_rules() { + use serde_json::json; + use trusted_server_core::config::{TrustedServerAppConfig, register_deploy_integrations}; + use trusted_server_core::test_support::tests::create_test_settings; + + register_deploy_integrations(builders()); + + TrustedServerAppConfig::new(create_test_settings()) + .expect("should accept the settings before a module's table is wrong"); + + let mut settings = create_test_settings(); + settings + .insert_module_config( + "cmp", + trusted_server_cmp_osano::MODULE, + &json!({ "typo": true }), + ) + .expect("should insert the module's table"); + + let error = TrustedServerAppConfig::new(settings) + .expect_err("should refuse a table the stock module's own rules reject"); + + let rendered = format!("{error:?}"); + assert!( + rendered.contains("[cmp.osano]"), + "should name the module's table: {rendered}" + ); + } + + /// Every documented table should be push-ready, so uncommenting its + /// section's selection and the table with the shown values must parse and + /// pass field validation. Tables that ship a deliberately invalid + /// placeholder that is not a secret (the Google Tag Manager + /// `container_id`) are left out. + #[test] + fn documented_module_tables_validate_when_uncommented_and_selected() { + use trusted_server_audience_permutive as permutive; + use trusted_server_cmp_sourcepoint as sourcepoint; + use trusted_server_core::settings::Settings; + use trusted_server_core::test_support::template::{ + template_with_resolved_required_secrets, uncomment_block, + }; + use trusted_server_identity_lockr as lockr; + + let base = template_with_resolved_required_secrets(); + + for (section, selection, header, name) in [ + ( + "[audience]", + "module = \"permutive\"", + "[audience.permutive]", + permutive::MODULE, + ), + ( + "[identity]", + "module = \"lockr\"", + "[identity.lockr]", + lockr::MODULE, + ), + ( + "[cmp]", + "module = \"sourcepoint\"", + "[cmp.sourcepoint]", + sourcepoint::MODULE, + ), + ] { + let toml = format!( + "{}\n{section}\n{selection}\n", + uncomment_block(&base, header) + ); + let settings = Settings::from_toml(&toml) + .unwrap_or_else(|err| panic!("uncommented {header} should parse: {err:?}")); + + let valid = match name { + permutive::MODULE => settings + .module_config::(name) + .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) + .is_some(), + lockr::MODULE => settings + .module_config::(name) + .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) + .is_some(), + _ => settings + .module_config::(name) + .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) + .is_some(), + }; + assert!(valid, "{header} should resolve to a valid config"); + } + } + + /// Every stock page module refuses a setting it does not know, so a + /// misspelt key in its table fails deploy validation naming the table and + /// the key, where it would otherwise be ignored. + #[test] + fn every_stock_module_rejects_a_setting_it_does_not_know() { + use serde_json::json; + use trusted_server_core::config::validate_settings_for_deploy_with; + use trusted_server_core::module_name::short_form; + use trusted_server_core::test_support::tests::create_test_settings; + + let stock = builders(); + for builder in stock + .iter() + .filter(|builder| builder.supplies_integration()) + { + let name = builder + .module_name() + .expect("every stock page module names itself"); + let section = builder + .section() + .expect("every stock page module has a section"); + let mut settings = create_test_settings(); + settings + .insert_module_config(section, name, &json!({ "no_such_setting": true })) + .expect("should insert the planted table"); + + let error = match validate_settings_for_deploy_with(&settings, &stock) { + Ok(()) => panic!("`{name}` should refuse a setting it does not know"), + Err(error) => format!("{error:?}"), + }; + let written = short_form(section, name); + assert!( + error.contains(&format!("[{section}.{written}]")) + && error.contains("no_such_setting"), + "`{name}` should name its table and the unknown setting: {error}" + ); + } + } + + /// The adapters run the registry's request preparers and name no module, + /// so this list is what attaches the diagnostics module's preparer. It + /// strips the module's reserved query and cookie in a deployment that + /// does not select the module, as it does in one that does. + #[test] + fn the_stock_list_attaches_the_diagnostics_module_s_request_preparer() { + use edgezero_core::body::Body as EdgeBody; + use http::{Method, Request, header}; + use trusted_server_core::integrations::IntegrationRegistry; + use trusted_server_core::test_support::tests::create_test_settings; + + let settings = create_test_settings(); + let registry = IntegrationRegistry::with_registrations(&settings, &builders()) + .expect("should build registry"); + let mut request = Request::builder() + .method(Method::GET) + .uri("https://publisher.example.com/article?ts_console=1&keep=yes") + .header(header::COOKIE, "__Host-ts-console=1; keep-me=yes") + .body(EdgeBody::empty()) + .expect("should build request"); + + registry + .prepare_request(&settings, &mut request) + .expect("should run the stock preparers"); + + assert_eq!( + request.uri().query(), + Some("keep=yes"), + "should strip the reserved diagnostics query and keep the rest" + ); + assert_eq!( + request + .headers() + .get(header::COOKIE) + .map(|value| value.to_str().expect("cookie should be text")), + Some("keep-me=yes"), + "should strip the reserved diagnostics cookie and keep the rest" + ); + } + + #[test] + fn a_stock_module_s_selection_is_found_by_its_integration_id() { + for (id, section, written) in [ + ("prebid", "auction", "prebid"), + ("datadome", "bot-protection", "datadome"), + ("didomi", "cmp", "didomi"), + ("sourcepoint", "cmp", "sourcepoint"), + ("osano", "cmp", "osano"), + ("lockr", "identity", "lockr"), + ("permutive", "audience", "permutive"), + ("nextjs", "framework", "nextjs"), + ("gpt", "ad-tag", "google"), + ("google_tag_manager", "tag", "google-tag-manager"), + // Testlight says `[auction]` selects it, where its full name is + // written. + ("testlight", "auction", "testing.testlight"), + ] { + assert_eq!( + selection_of(id), + Some((section, written)), + "should find where `{id}` is selected" + ); + } + assert_eq!( + selection_of("no-such-integration"), + None, + "should find nothing for an id no stock module has" + ); + } + + /// The `ts` tool registers the stock builders, so the secret settings a + /// stock module declares are among the leaves a push treats as naming a + /// key. + #[test] + fn a_stock_module_s_secret_settings_are_listed_once_the_list_is_registered() { + use edgezero_core::app_config::AppConfigMeta as _; + use trusted_server_core::config::{TrustedServerAppConfig, register_deploy_integrations}; + + register_deploy_integrations(builders()); + + let paths = TrustedServerAppConfig::secret_fields() + .iter() + .map(edgezero_core::app_config::SecretField::dotted_path) + .collect::>(); + + for expected in [ + "bot-protection.datadome.server_side_key_secret_name", + "bot-protection.datadome.protection_test_bypass.credential_secret_name", + ] { + assert!( + paths.iter().any(|path| path == expected), + "should list `{expected}` among {paths:?}" + ); + } + } + + /// The mock ad server decides between the bids APS returned, and the + /// winner keeps the seat the exchange returned, APS's own bidder name and + /// its renderer. + #[tokio::test] + async fn the_mock_ad_server_keeps_an_aps_winner_s_identities_and_renderer() { + use std::collections::{BTreeMap, HashMap}; + use std::sync::Arc; + + use trusted_server_ad_server_mock::{AdServerMockProvider, AdServerMockSettings}; + use trusted_server_core::auction::orchestrator::AuctionOrchestrator; + use trusted_server_core::auction::types::{ + AdFormat, AdSlot, AuctionContext, AuctionRequest, MediaType, PublisherInfo, UserInfo, + }; + use trusted_server_core::platform::test_support::{ + StubHttpClient, build_services_with_http_client, + }; + use trusted_server_core::provider_table::ProviderList; + use trusted_server_core::test_support::tests::create_test_settings; + + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "upstream-seat", "bid": [{ + "id": "aps-bid", "impid": "fictional-slot", "price": 2.0, + "w": 300, "h": 250, + "ext": {"creativeurl": "https://creative.example/render", "tagtype": "iframe"} + }]}] + })) + .expect("should serialize APS response"), + ); + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid": [{"seat": "aps_instance", "bid": [{ + "id": "adserver-aps", "impid": "fictional-slot", "price": 2.0, + "adm": "ignored", "w": 300, "h": 250, "crid": "aps-creative" + }]}] + })) + .expect("should serialize adserver response"), + ); + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + + let mut settings = create_test_settings(); + settings.auction.enabled = true; + settings.auction.timeout_ms = 777; + let serde_json::Value::Object(aps) = serde_json::json!({ + "implementation": "auction.aps", + "endpoint": "https://aps.example/e/pb/bid", + "account_id": "example-account", + "timeout_ms": 1000, + "routing": "all_eligible", + }) else { + panic!("should build the APS table"); + }; + settings.demand = ProviderList::new( + vec!["aps_instance".to_string()], + BTreeMap::from([("aps_instance".to_string(), aps)]), + ); + let plan = trusted_server_core::auction::compile_auction_plan_with(&settings, &builders()) + .expect("should compile the planned APS auction"); + let adserver = AdServerMockProvider::new( + "adserver_mock", + AdServerMockSettings { + endpoint: "https://adserver.example/mediate".to_string(), + timeout_ms: 500, + ..AdServerMockSettings::default() + }, + ); + let orchestrator = AuctionOrchestrator::from_plan(Arc::new(plan), Some(Arc::new(adserver))); + let request = AuctionRequest { + id: "fictional-auction".to_string(), + slots: vec![AdSlot { + id: "fictional-slot".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: Some(1.0), + targeting: HashMap::new(), + bidders: HashMap::new(), + }], + publisher: PublisherInfo { + domain: "publisher.example".to_string(), + page_url: Some("https://publisher.example/article".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + }; + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should decide planned APS bid"); + + let provider_bid = &result.provider_responses[0].bids[0]; + assert_eq!(result.provider_responses[0].provider, "aps_instance"); + assert_eq!(provider_bid.returned_seat.as_deref(), Some("upstream-seat")); + assert_eq!(provider_bid.bidder, "aps"); + let winner = &result.winning_bids["fictional-slot"]; + assert_eq!(winner.returned_seat.as_deref(), Some("upstream-seat")); + assert_eq!(winner.bidder, "aps"); + assert!(winner.renderer.is_some()); + assert!(winner.creative.is_none()); + assert_eq!( + result + .adserver_response + .as_ref() + .map(|response| response.provider.as_str()), + Some("adserver_mock") + ); + } + + /// Every crate outside core says who maintains it, in the + /// `[package.metadata.maintainers]` table of its manifest. The manifests + /// are read from the source tree, which a Wasm test run cannot reach. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn every_crate_outside_core_declares_its_maintainers() { + const STATUSES: [&str; 3] = ["vendor owned", "seeking vendor owner", "project owned"]; + + let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("should have the crates folder above this crate"); + let mut manifests = vec![crates.join("trusted-server-modules").join("Cargo.toml")]; + for entry in std::fs::read_dir(crates).expect("should list the crates folder") { + let type_folder = entry.expect("should read a folder entry").path(); + // A crate directly under `crates/` is core, an adapter or a tool. + // A module crate is one folder further down, under its type. + if !type_folder.is_dir() || type_folder.join("Cargo.toml").is_file() { + continue; + } + for entry in std::fs::read_dir(&type_folder).expect("should list a type folder") { + let manifest = entry + .expect("should read a folder entry") + .path() + .join("Cargo.toml"); + if manifest.is_file() { + manifests.push(manifest); + } + } + } + assert!( + manifests.len() > 20, + "should find the module crates, found {}", + manifests.len() + ); + + for manifest in manifests { + let text = std::fs::read_to_string(&manifest).expect("should read a manifest"); + let Some((_, after)) = text.split_once("[package.metadata.maintainers]") else { + panic!("{} should declare its maintainers", manifest.display()); + }; + let table = after.split("\n[").next().unwrap_or_default(); + let value = |key: &str| { + table.lines().find_map(|line| { + line.strip_prefix(key)? + .trim_start() + .strip_prefix('=')? + .trim() + .strip_prefix('"')? + .strip_suffix('"') + }) + }; + assert!( + value("owner").is_some_and(|owner| !owner.is_empty()), + "{} should name an owner", + manifest.display() + ); + let status = value("status").unwrap_or_default(); + assert!( + STATUSES.contains(&status), + "{} should state one of {STATUSES:?}, and states `{status}`", + manifest.display() + ); + } + } +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4220851797..11bcff0f5b 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/ad-serving.md b/docs/guide/ad-serving.md index 171764bc18..978a97a595 100644 --- a/docs/guide/ad-serving.md +++ b/docs/guide/ad-serving.md @@ -15,34 +15,37 @@ publisher proxy path. `POST /auction` accepts the documented auction request shape. When `[auction].enabled = true`, the compiled `AuctionPlan` selects configured providers, routes bidder codes, executes supported provider fan-out, applies an -optional mediator, and returns the winning bids. When auctions are disabled, +optional ad server, and returns the winning bids. When auctions are disabled, the endpoint returns a no-bid response without contacting a provider. -## Shipped demand and mediation +## Shipped demand and the ad server -Provider instances are declared under `[auction.providers.]`. The shipped -profiles are: +Demand sources are declared under `[demand.]`, each naming the +implementation it runs. The shipped implementations are: -- `standard` for a generic OpenRTB 2.6 endpoint; -- `prebid-server` for Prebid Server request controls; and -- `aps` for the APS OpenRTB contract and typed renderer response. +- `auction-protocol.openrtb` for a generic OpenRTB 2.6 endpoint; +- `auction.prebid-server` for Prebid Server request controls; and +- `auction.aps` for the APS OpenRTB contract and typed renderer response. Browser-visible bidder codes are mapped separately under -`[auction.bidders.]`. `adserver_mock` is the only registered mediator; it -is optional and is configured under `[integrations.adserver_mock]`. +`[auction.bidders.]`. `ad-server.mock` is the only shipped ad server; it +is optional, selected by `[ad-server] module = "mock"` and configured under +`[ad-server.mock]`. ```toml [auction] enabled = true -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +modules = ["pbs_main"] + +[demand.pbs_main] +implementation = "auction.prebid-server" endpoint = "https://prebid.example.com/openrtb2/auction" routing = "explicit" [auction.bidders.example-bidder] -provider = "pbs-main" +module = "pbs_main" ``` Deploy validation compiles this configuration before publication. Adapter diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index f6c8f72d31..530dc94a92 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -8,6 +8,7 @@ Quick reference for all Trusted Server HTTP endpoints. - [Edge Cookie Endpoints](#edge-cookie-endpoints) - Identity sync and enrichment - [Request Signing](#request-signing-endpoints) - Cryptographic signing and key management - [Admin Diagnostics](#admin-diagnostic-endpoints) - Protected EC troubleshooting +- [Inspection Endpoints](#inspection-endpoints) - What a deployment shows to anyone who asks - [TSJS Library](#tsjs-library-endpoint) - JavaScript library serving - [Utility Endpoints](#utility-endpoints) - Optional operational helpers - [Integration Endpoints](#integration-endpoints) - Third-party service proxying @@ -45,9 +46,14 @@ authority, scheme, and client-address headers in the innermost middleware. | normal | `/_ts/api/v1/batch-sync` | `POST` | literal | `always` | real | — | — | — | | normal | `/_ts/api/v1/identify` | `GET`, `OPTIONS` | literal | `always` | real | — | — | — | | normal | `/_ts/clear-tester` | `GET` | literal | `settings.tester_cookie.enabled` | real | — | — | — | +| normal | `/_ts/config.json` | `GET` | literal | `always` | real | real | real | real | +| normal | `/_ts/config` | `GET` | literal | `always` | real | real | real | real | +| normal | `/_ts/data` | `GET` | literal | `always` | real | real | real | real | | normal | `/_ts/debug/ja4` | `GET` | conditional | `settings.debug.ja4_endpoint_enabled` | real | — | — | — | | normal | `/_ts/page-bids` | `GET` | literal | `always` | real | real | real | real | | normal | `/_ts/page-bids` | `OPTIONS` | literal | `always` | guarded | guarded | guarded | guarded | +| normal | `/_ts/permissions.json` | `GET` | literal | `always` | real | real | real | real | +| normal | `/_ts/permissions` | `GET` | literal | `always` | real | real | real | real | | normal | `/_ts/set-tester` | `GET` | literal | `settings.tester_cookie.enabled` | real | — | — | — | | normal | `/admin/keys/deactivate` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | literal | `always` | guarded | guarded | guarded | guarded | | normal | `/admin/keys/rotate` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | literal | `always` | guarded | guarded | guarded | guarded | @@ -89,6 +95,211 @@ JSON, some return an empty body, and shared adapter errors are plain text with safe client-facing messages. Treat the status and body documented for each endpoint as authoritative. +## Inspection Endpoints + +What a deployment shows to anyone who asks, with no credential. A publisher +can point a reader, a regulator or an auditor at these addresses, and each +sees what the deployment decided for their own request. + +### GET /\_ts/config and GET /\_ts/config.json + +Shows the settings the deployment is running, after defaults are applied. +Anyone can read the scripts a page runs, and this lets anyone read the +configuration that runs at the edge too. Every secret is masked, whatever the +publisher's document says, and every value that is sensitive by default is +masked unless the publisher shows it. The publisher decides what else is shown +or hidden in [`[inspect]`](/guide/configuration#inspect), and `config = false` +there answers `404 Not Found`. The first address answers a page, and the +second answers the same information as JSON. + +**Contract:** Auth: none. Request body: not applicable. Rate limit: none. +Endpoint-specific CORS: `Access-Control-Allow-Origin: *`. + +**Response:** + +- **Status:** `200 OK` +- **Headers:** `Cache-Control: no-store` + +```json +{ + "masked": [ + "handlers[0].password", + "handlers[0].path", + "handlers[0].username", + "publisher.origin_url", + "publisher.proxy_secret" + ], + "settings": { + "handlers": [{ "password": "XXXX", "path": "XXXX", "username": "XXXX" }], + "publisher": { + "cookie_domain": ".example.com", + "domain": "example.com", + "origin_url": "XXXX", + "proxy_secret": "XXXX" + } + }, + "version": "0.1.0" +} +``` + +| Field | Meaning | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings` | The running settings, with each masked value replaced by `XXXX` and every object's keys sorted, so the same settings always give the same bytes | +| `masked` | Every masked path, sorted, so a reader can see what was withheld | +| `version` | The version of the core crate that answered | + +A secret is masked twice over. The settings loader records each value it fills +from the secret store, and those paths are masked. Then any string that still +contains one of those values is masked wherever it sits, a key included. + +**Example:** + +```bash +curl -s "https://edge.example.com/_ts/config.json" +``` + +### GET /\_ts/data + +Shows a reader what the deployment holds against the Edge Cookie their own +browser sent, being when the record was made, the consent recorded, the +location and device class kept, and which partners hold an identifier of their +own for the same browser. It answers as a page only, because the answer is for +a person. + +**Contract:** Auth: none. The record shown is the one stored against the +identifier in the request's own `ts-ec` cookie, and no other can be asked for. +Request body: not applicable. Rate limit: none. Endpoint-specific CORS: none, +so no other origin may read it. + +The Edge Cookie is `HttpOnly`, so a script on the page cannot read the +identifier, and this page does not undo that. + +1. Only a browser opening the address as a page is answered, which the browser + marks with `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document`. A + script cannot set either header, so a `fetch` from a page, a frame and an + embedded object are each answered `403 Forbidden`. A browser sends these + headers only to a trustworthy origin, so every browser on a plain `http://` + host other than localhost is answered `403 Forbidden` too. +2. The page is sent with `Content-Security-Policy: sandbox`, which gives the + document an origin of its own, so a page that opened it cannot read it. +3. No identifier is shown. The Edge Cookie's value and each partner's + identifier show as `XXXX`. + +Two parties other than the reader can still read the page, and neither finds +an identifier in it. The first is whoever holds an identifier, because a tool +can send it as the cookie along with both headers. The second is a service +worker the publisher's site registers for the whole site, because it handles +this navigation as it does every other on the site. + +**Response:** + +- **Status:** `200 OK`, or `403 Forbidden` when the request is not a browser + opening the page +- **Headers:** `Cache-Control: no-store, private`, `X-Frame-Options: DENY`, + `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, + `Cross-Origin-Resource-Policy: same-origin` and the policy below + +```http +Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none' +``` + +The page shows this information. + +```json +{ + "consent_updated": "2026-03-14T09:30:00Z", + "created": "2026-03-14T09:30:00Z", + "held": true, + "identifier": "XXXX", + "record": { + "consent": { "ok": true, "tcf": "CP...", "updated": 1773480600 }, + "created": 1773480600, + "geo": { "country": "GB", "region": "ENG" }, + "ids": { "partner.example.com": { "uid": "XXXX" } }, + "pub_properties": { + "origin_domain": "example.com", + "seen_domains": ["example.com"] + }, + "v": 1 + }, + "version": "0.1.0", + "withdrawn": false +} +``` + +| Field | Meaning | +| ------------------------------- | ----------------------------------------------------------------------------- | +| `held` | Whether a record is held against the request's Edge Cookie | +| `why` | The reason, when nothing is held | +| `identifier` | Always `XXXX`, because the Edge Cookie's value is never shown | +| `record` | The record as it is stored, with each partner's identifier replaced by `XXXX` | +| `created` and `consent_updated` | The two times in the record, written as dates | +| `withdrawn` | Whether the record is the one a withdrawal of consent leaves | +| `version` | The version of the core crate that answered | + +Nothing is held, and `why` gives the reason, when the deployment keeps no +identity graph, when the request carried no Edge Cookie, when the cookie is +not one the deployment issued, and when no record is stored against it. Only +the Fastly adapter keeps an identity graph, so every other adapter answers +that nothing is held. + +Nothing is created or written for the reader. The Edge Cookie lifecycle does +not run for this request, so no cookie is set and no identity row is written. + +### GET /\_ts/permissions and GET /\_ts/permissions.json + +Shows the permissions resolved for the request that asks, the signals that +produced them and the terms the data is held under. It is the evaluation a +page receives as `window.tsjs.permissions`, resolved from the request's own +signals and location. The first address answers a page, and the second +answers the same information as JSON. + +**Contract:** Auth: none. Request body: not applicable. Rate limit: none. +Endpoint-specific CORS: `Access-Control-Allow-Origin: *`, so a page on another +site can render the data. A request from another origin carries no cookies, so +that page reads the resolution of a request with no stored signal. + +**Response:** + +- **Status:** `200 OK` +- **Headers:** `Cache-Control: no-store`, because each answer is the asking + request's own. + +```json +{ + "awaiting": [], + "modules": { "configured": ["gpc", "tcf"], "contributed": ["tcf"] }, + "set": ["necessary.operations.storage"], + "signals": [{ "module": "tcf", "scheme": "tcf", "value": "CP..." }], + "storageWithdrawn": false, + "tdls": [], + "version": "0.1.0" +} +``` + +| Field | Meaning | +| --------------------- | --------------------------------------------------------------------------------------------------------- | +| `set` | The Data Use identifiers that are set for this request | +| `awaiting` | The ones still waiting for a signal that a configured module could give | +| `signals` | Each signal a module read and found valid, as it was received, with the module that read it | +| `tdls` | The terms documents the data is available under | +| `storageWithdrawn` | Whether the request explicitly withdrew device storage, as distinct from not setting it | +| `modules.contributed` | The signal modules that produced a signal used in this resolution | +| `modules.configured` | The signal modules the publisher configured, or `null` when it named none and the adapter's own list runs | +| `version` | The version of the core crate that answered | + +A module in `configured` and not in `contributed` ran and found nothing to +work with, which is a different fact from a module that was never configured. + +Nothing is created or written for the reader. The Edge Cookie lifecycle does +not run for this request, so no cookie is set and no identity row is written. + +**Example:** + +```bash +curl -s "https://edge.example.com/_ts/permissions.json" -H "Sec-GPC: 1" +``` + ## Utility Endpoints ### GET /\_ts/set-tester @@ -174,7 +385,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 @@ -183,7 +394,7 @@ provider. An oversized body returns `413`; malformed input and validation failures use the shared adapter error mapping; provider failures use the shared `5xx` mapping. The endpoint sets no dedicated cache or CORS policy and has no in-process rate limiter. Provider selection, timeouts, and fan-out are governed -by `[auction]` and the enabled provider profiles. +by `[auction]` and the demand sources `[demand]` selects. **Request Body:** @@ -290,7 +501,7 @@ The `200`, `400`, and `403` terminal responses are `private, no-store`. The endpoint has no built-in authentication, CORS grant, or rate limiter. **Configuration:** `[creative_opportunities]`, `[auction]`, slot page patterns, -provider profiles, and consent settings. Bot and prefetch requests retain slot +demand sources, and consent settings. Bot and prefetch requests retain slot shape but skip live provider calls. **Example:** @@ -424,6 +635,18 @@ mappings are accepted even though the second A appears after B in the input. --- +### POST /\_ts/api/v1/ec/resolve + +Resolve endpoint for client-side Edge Cookie modules. The page posts a value that the module verifies and creates the Edge Cookie value. Used only when a client-side module is selected (for example the `client_fixed` demonstration module). Server-side modules 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 module is responsible for verifying the posted value before trusting it. + +**Request Body:** the module'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 module is configured, no identity graph is available, or the module 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.). @@ -863,13 +1086,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 module this deployment selects, such as the built-in HMAC module's `hmac~{64 hex}.{6 alphanumeric}` form. The built-in HMAC module also still reads the bare legacy `{64 hex}.{6 alphanumeric}` form, and a deployment with no module 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 module 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. @@ -901,7 +1124,7 @@ curl -u 'admin:' \ ### GET /\_ts/admin/eids -Parses the request's `ts-eids` and `sharedId` cookies and previews which configured partner IDs cookie ingestion would match or drop. It performs request inspection only: it does not read or write KV and is available on every adapter. The preview lists candidate matches from the cookies alone; it does not apply the TCF Purpose 1 + 4 consent gate on identity-graph writes, does not see `/auction` request-body EIDs, and does not account for partner UIDs already stored in KV. +Parses the request's `ts-eids` and `sharedId` cookies and previews which configured partner IDs cookie ingestion would match or drop. It performs request inspection only: it does not read or write KV and is available on every adapter. The preview lists candidate matches from the cookies alone; it does not apply the permission gate on identity-graph writes, does not see `/auction` request-body EIDs, and does not account for partner UIDs already stored in KV. After successful authentication this endpoint always returns `200 OK`; missing or malformed cookies are represented by `cookie_present`, `sharedid_present`, and `parse_error`. The `ingest.matched` and `ingest.unmatched` arrays show the ingestion preview. Each unmatched entry contains its `source` and either a `no_partner` reason when no configured partner recognizes it or `no_valid_uid` when the partner exists but every supplied UID is empty or exceeds the storage limit. @@ -1047,7 +1270,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 the module sections select in `trusted-server.toml`. No rebuild is required to change the module set. --- @@ -1055,65 +1278,67 @@ All integration modules are built at compile time. At runtime, the server concat Every row below records a compiled integration registration predicate. An integration with no HTTP route can still contribute a browser module, -rewriter, injector, post-processor, request filter, or auction mediator. - -| Integration | Registration predicate | HTTP routes | -| -------------------- | ---------------------------------------------------------------- | ------------------------------------------------- | -| `adserver_mock` | `auction.mediator=adserver_mock;enabled=true` | None | -| `aps` | `plan.has_profile(aps);rendering_mode=publisher_native` | None | -| `aps` | `plan.has_profile(aps);rendering_mode=trusted_server` | `GET /integrations/aps/renderer` | -| `creative` | `always` | None | -| `datadome` | `enabled=true;enable_protection=false` | `GET /integrations/datadome/js/*` | -| `datadome` | `enabled=true;enable_protection=false` | `GET /integrations/datadome/js/` | -| `datadome` | `enabled=true;enable_protection=false` | `GET /integrations/datadome/tags.js` | -| `datadome` | `enabled=true;enable_protection=false` | `POST /integrations/datadome/js/*` | -| `datadome` | `enabled=true;enable_protection=false` | `POST /integrations/datadome/js/` | -| `datadome` | `enabled=true;enable_protection=true` | `GET /integrations/datadome/js/*` | -| `datadome` | `enabled=true;enable_protection=true` | `GET /integrations/datadome/js/` | -| `datadome` | `enabled=true;enable_protection=true` | `GET /integrations/datadome/tags.js` | -| `datadome` | `enabled=true;enable_protection=true` | `POST /integrations/datadome/js/*` | -| `datadome` | `enabled=true;enable_protection=true` | `POST /integrations/datadome/js/` | -| `didomi` | `enabled=true;prefix=proxy_path\|\|/integrations/didomi/consent` | `GET /*` | -| `didomi` | `enabled=true;prefix=proxy_path\|\|/integrations/didomi/consent` | `POST /*` | -| `google_tag_manager` | `enabled=true` | `GET /integrations/google_tag_manager/collect` | -| `google_tag_manager` | `enabled=true` | `GET /integrations/google_tag_manager/g/collect` | -| `google_tag_manager` | `enabled=true` | `GET /integrations/google_tag_manager/gtag.js` | -| `google_tag_manager` | `enabled=true` | `GET /integrations/google_tag_manager/gtag/js` | -| `google_tag_manager` | `enabled=true` | `GET /integrations/google_tag_manager/gtm.js` | -| `google_tag_manager` | `enabled=true` | `POST /integrations/google_tag_manager/collect` | -| `google_tag_manager` | `enabled=true` | `POST /integrations/google_tag_manager/g/collect` | -| `gpt_diagnostics` | `enabled=true` | None | -| `js_asset_proxy` | `enabled=true;asset.proxy=enabled` | `GET ` per configured asset | -| `gpt` | `enabled=true` | `GET /integrations/gpt/pagead/*` | -| `gpt` | `enabled=true` | `GET /integrations/gpt/script` | -| `gpt` | `enabled=true` | `GET /integrations/gpt/tag/*` | -| `lockr` | `enabled=true` | `GET /integrations/lockr/api/*` | -| `lockr` | `enabled=true` | `GET /integrations/lockr/sdk` | -| `lockr` | `enabled=true` | `POST /integrations/lockr/api/*` | -| `nextjs` | `enabled=true` | None | -| `osano` | `enabled=true` | None | -| `permutive` | `enabled=true` | `GET /integrations/permutive/api/*` | -| `permutive` | `enabled=true` | `GET /integrations/permutive/cdn/*` | -| `permutive` | `enabled=true` | `GET /integrations/permutive/events/*` | -| `permutive` | `enabled=true` | `GET /integrations/permutive/sdk` | -| `permutive` | `enabled=true` | `GET /integrations/permutive/secure-signal/*` | -| `permutive` | `enabled=true` | `GET /integrations/permutive/sync/*` | -| `permutive` | `enabled=true` | `POST /integrations/permutive/api/*` | -| `permutive` | `enabled=true` | `POST /integrations/permutive/events/*` | -| `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 ` | -| `sourcepoint` | `enabled=true` | `GET /integrations/sourcepoint/cdn/*` | -| `sourcepoint` | `enabled=true` | `HEAD /integrations/sourcepoint/cdn/*` | -| `sourcepoint` | `enabled=true` | `OPTIONS /integrations/sourcepoint/cdn/*` | -| `sourcepoint` | `enabled=true` | `POST /integrations/sourcepoint/cdn/*` | -| `testlight` | `enabled=true` | `POST /integrations/testlight/auction` | +rewriter, injector, post-processor, request filter, or ad server. In the +predicates below, `named` means a section selects the integration's module. + +| Integration | Registration predicate | HTTP routes | +| -------------------- | ------------------------------------------------------------------- | ------------------------------------------------- | +| `adserver_mock` | `[ad-server] module = "mock"` | None | +| `aps` | `demand implementation=auction.aps;rendering_mode=publisher_native` | None | +| `aps` | `demand implementation=auction.aps;rendering_mode=trusted_server` | `GET /integrations/aps/renderer` | +| `creative` | `always` | None | +| `datadome` | `named;enable_protection=false` | `GET /integrations/datadome/js/*` | +| `datadome` | `named;enable_protection=false` | `GET /integrations/datadome/js/` | +| `datadome` | `named;enable_protection=false` | `GET /integrations/datadome/tags.js` | +| `datadome` | `named;enable_protection=false` | `POST /integrations/datadome/js/*` | +| `datadome` | `named;enable_protection=false` | `POST /integrations/datadome/js/` | +| `datadome` | `named;enable_protection=true` | `GET /integrations/datadome/js/*` | +| `datadome` | `named;enable_protection=true` | `GET /integrations/datadome/js/` | +| `datadome` | `named;enable_protection=true` | `GET /integrations/datadome/tags.js` | +| `datadome` | `named;enable_protection=true` | `POST /integrations/datadome/js/*` | +| `datadome` | `named;enable_protection=true` | `POST /integrations/datadome/js/` | +| `didomi` | `named;prefix=proxy_path\|\|/integrations/didomi/consent` | `GET /*` | +| `didomi` | `named;prefix=proxy_path\|\|/integrations/didomi/consent` | `POST /*` | +| `google_tag_manager` | `named` | `GET /integrations/google_tag_manager/collect` | +| `google_tag_manager` | `named` | `GET /integrations/google_tag_manager/g/collect` | +| `google_tag_manager` | `named` | `GET /integrations/google_tag_manager/gtag.js` | +| `google_tag_manager` | `named` | `GET /integrations/google_tag_manager/gtag/js` | +| `google_tag_manager` | `named` | `GET /integrations/google_tag_manager/gtm.js` | +| `google_tag_manager` | `named` | `POST /integrations/google_tag_manager/collect` | +| `google_tag_manager` | `named` | `POST /integrations/google_tag_manager/g/collect` | +| `gpt_diagnostics` | `named` | None | +| `js_asset_proxy` | `named;asset.proxy=enabled` | `GET ` per configured asset | +| `gpt` | `named` | `GET /integrations/gpt/pagead/*` | +| `gpt` | `named` | `GET /integrations/gpt/script` | +| `gpt` | `named` | `GET /integrations/gpt/tag/*` | +| `lockr` | `named` | `GET /integrations/lockr/api/*` | +| `lockr` | `named` | `GET /integrations/lockr/sdk` | +| `lockr` | `named` | `POST /integrations/lockr/api/*` | +| `nextjs` | `named` | None | +| `osano` | `named` | None | +| `permutive` | `named` | `GET /integrations/permutive/api/*` | +| `permutive` | `named` | `GET /integrations/permutive/cdn/*` | +| `permutive` | `named` | `GET /integrations/permutive/events/*` | +| `permutive` | `named` | `GET /integrations/permutive/sdk` | +| `permutive` | `named` | `GET /integrations/permutive/secure-signal/*` | +| `permutive` | `named` | `GET /integrations/permutive/sync/*` | +| `permutive` | `named` | `POST /integrations/permutive/api/*` | +| `permutive` | `named` | `POST /integrations/permutive/events/*` | +| `permutive` | `named` | `POST /integrations/permutive/secure-signal/*` | +| `permutive` | `named` | `POST /integrations/permutive/sync/*` | +| `prebid` | `named;script_patterns=config-derived` | `GET /integrations/prebid/bundle.js` | +| `prebid` | `named;script_patterns=config-derived` | `GET ` | +| `sourcepoint` | `named` | `GET /integrations/sourcepoint/cdn/*` | +| `sourcepoint` | `named` | `HEAD /integrations/sourcepoint/cdn/*` | +| `sourcepoint` | `named` | `OPTIONS /integrations/sourcepoint/cdn/*` | +| `sourcepoint` | `named` | `POST /integrations/sourcepoint/cdn/*` | +| `testlight` | `named` | `POST /integrations/testlight/auction` | ### Integration proxy contracts All integration routes are registered only when the documented predicate is -true. A disabled integration registers no route, so its path continues through +true. An integration no section selects registers no route, +so its path continues through normal routing and can reach the publisher fallback. Duplicate registrations are startup errors. None of these routes has built-in caller authentication or an in-process rate limiter; `[[handlers]]` and platform controls remain @@ -1121,13 +1346,13 @@ available when a deployment needs either. | Route family | Request and success contract | Errors, cache, and CORS | Example | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| APS renderer | `GET /integrations/aps/renderer`; no body; `200 text/html` static opaque-frame renderer with CSP, `nosniff`, and `no-referrer` | Present only for an enabled APS profile in `trusted_server` rendering mode; no dedicated cache/CORS headers | Browser-internal iframe target; curl is not a meaningful auction test | +| APS renderer | `GET /integrations/aps/renderer`; no body; `200 text/html` static opaque-frame renderer with CSP, `nosniff`, and `no-referrer` | Present only for an APS demand source in `trusted_server` rendering mode; no dedicated cache/CORS headers | Browser-internal iframe target; curl is not a meaningful auction test | | DataDome tag | `GET /integrations/datadome/tags.js`; query forwarded; successful upstream `200` is rewritten and returned as JavaScript | Non-200 upstream status is preserved; rewritten `200` uses `cache_ttl_seconds`; upstream `Access-Control-Allow-Origin` is copied when present | `curl -i https://edge.example.com/integrations/datadome/tags.js` | | DataDome signals | `GET` or `POST /integrations/datadome/js/` and `/js/*`; method, query, bounded POST body, and selected browser headers forwarded to `api_origin` | Upstream status/body/headers preserved; transport or size failures use shared integration errors; no added cache/CORS policy | Browser SDK traffic; manual payload is upstream-schema-specific | | 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 `[[proxy.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 | @@ -1140,7 +1365,7 @@ available when a deployment needs either. `adserver_mock`, `creative`, `gpt_diagnostics`, `nextjs`, and `osano` have no HTTP endpoint. Their request schema, statuses, cache/CORS contract, rate limit, -and curl example are therefore not applicable; their browser, mediator, +and curl example are therefore not applicable; their browser, ad server, rewriter, or diagnostic behavior is documented in the integration guides. ### Prebid Integration @@ -1280,7 +1505,11 @@ universal numeric limits. CORS is endpoint-specific. `/_ts/api/v1/identify` implements a closed, publisher-domain policy and an explicit `OPTIONS` handler. Page-bids explicitly -denies preflight. Several integration proxies preserve or synthesize only the +denies preflight. `/_ts/permissions` and `/_ts/permissions.json` answer any +origin, because they show nothing but the asking request's own resolution, +and `/_ts/config` and `/_ts/config.json` answer any origin, because what they +show is the same for every request. `/_ts/data` answers no other origin, +because it shows what is held against one reader. Several integration proxies preserve or synthesize only the headers described in their contract. `response_headers` is standard response finalization, not a substitute for registering and validating an `OPTIONS` route; do not infer cross-origin support from a configured header alone. diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index a6145f80d3..0db1d0dd98 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] modules` 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 `[ad-server] module`. ### Consent-Aware Design diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index edbc384719..83eb258b98 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:
[ad-server]
module = "mock" → parallel_adserver
No [ad-server] → 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]`, `[ad-server]` 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 +- `auction-protocol.openrtb` for the common banner subset and bounded static extensions; +- `auction.prebid-server` for PBS request, response, cache, override, and diagnostics behavior; and -- `aps` for APS account/SDK fields, response eligibility, and renderer output. +- `auction.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 `ad-server.mock` ad server is selected the same way, by +`[ad-server] module`, and supplies an ad server implementation rather than a +demand one. See [Configuration Rules](/guide/configuration-rules) for the +syntax every 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] +modules = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "auction.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 = "auction.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" +module = "pbs_main" -# No mediator — direct price comparison +# No [ad-server], 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] +modules = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "auction.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 = "auction.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" +module = "pbs_main" -[integrations.adserver_mock] -enabled = true -endpoint = "https://mediator.example.com/mediate" +[ad-server] +module = "mock" + +[ad-server.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] +modules = ["pbs_main"] + +[demand.pbs_main] +implementation = "auction.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" +module = "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] +modules = ["aps_main"] + +[demand.aps_main] +implementation = "auction.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" +[ad-server] +module = "mock" + +[ad-server.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` @@ -699,44 +707,47 @@ Each proxied URL includes a `tstoken` HMAC signature for tamper protection. See ```toml [auction] +modules = ["prebid"] 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] +modules = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "auction.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 = "auction.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" +module = "pbs_main" -[integrations.prebid] -enabled = true +[ad-server] +module = "mock" + +[ad-server.mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 + +[auction.prebid] timeout_ms = 1000 debug = false client_side_bidders = ["example-browser"] @@ -744,43 +755,40 @@ 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] modules` 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 `[ad-server] module`. -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, `auction-protocol.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. +never another source's bidder parameters. An `explicit` source receives only +centrally routed or trusted stored-request demand. The `auction.prebid-server` +implementation rejects `all_eligible` because PBS requires bidder or +stored-request demand on each impression. For PBS, `trustedServer.params.storedRequest` controls stored fallback, not -provider selection. `false` suppresses stored demand on every PBS instance; +demand source selection. `false` suppresses stored demand on every PBS instance; `true` permits it; omission retains legacy empty-envelope and routed-empty-param fallback. Invalid intent, including `null`, rejects the envelope atomically. After provider-local overrides, usable inline params win. Otherwise only permitted stored demand becomes `imp.ext.prebid.storedrequest.id`, using the slot code. -Demandless impressions are omitted, and a provider with no remaining impressions +Demandless impressions are omitted, and a source with no remaining impressions makes no transport request. Server-owned explicit routes remain internal and do not authorize demandless PBS wire impressions. APS and standard `all_eligible` participation is unchanged. Intentional and legacy stored demand still fan out to @@ -790,23 +798,24 @@ New TSJS envelopes explicitly disable stored fallback, while publisher-authored omission remains compatible. Follow the [server-first deployment sequence](/guide/integrations/prebid#stored-intent-deployment) before serving new JS; Rust builds embed those bundles. -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. +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 `auction-protocol.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. @@ -814,20 +823,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 `[auction.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 @@ -835,20 +846,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 selected +name is a module name, so it 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 selector list is an array, so `[demand] modules` and +`[ad-server] module` 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 @@ -859,19 +871,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 @@ -882,8 +894,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 @@ -891,7 +903,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 @@ -911,7 +923,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" @@ -926,13 +938,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/auction-testing.md b/docs/guide/auction-testing.md index b92b78b0fc..eeffbddde9 100644 --- a/docs/guide/auction-testing.md +++ b/docs/guide/auction-testing.md @@ -6,8 +6,8 @@ tests at production bidder endpoints. ## Validate the configuration first The CLI compiles the complete target-independent `AuctionPlan`, including -provider profiles, bidder routes, extension bounds, notification settings, and -mediator selection: +demand sources, bidder routes, extension bounds, notification settings, and +the ad server selection: ```bash ts config validate --strict @@ -40,14 +40,14 @@ curl --fail-with-body \ Check the response and logs for these invariants: -- a disabled auction returns a no-bid response without provider dispatch; -- `routing = "explicit"` sends a slot only to its mapped provider; +- a disabled auction returns a no-bid response without demand dispatch; +- `routing = "explicit"` sends a slot only to its mapped demand source; - `routing = "all_eligible"` sends eligible banner slots without leaking - another provider's bidder parameters; -- provider-local failures do not fabricate bids; -- with no mediator, the orchestrator selects the highest valid bid per slot; -- with `mediator = "adserver_mock"`, the configured mediator owns final - selection; and + another demand source's bidder parameters; +- a failure local to one demand source does not fabricate bids; +- with no ad server, the orchestrator selects the highest valid bid per slot; +- with `[ad-server] module = "mock"`, the configured ad server owns + final selection; and - APS winners use `ext.trusted_server.renderer` rather than raw `adm`. ## Run automated coverage diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 8b851bd82c..364f9847cf 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -304,13 +304,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 selects, in the section of each module's type, the modules it +can configure from what it found, and writes their tables. Where it found +third-party scripts it writes `[proxy.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: @@ -675,7 +676,10 @@ APIs. > is not accepted as an alias. ```toml -[integrations.prebid.bundle.modules] +[auction] +modules = ["prebid"] + +[auction.prebid.bundle.modules] bidder = ["rubiconBidAdapter", "kargoBidAdapter"] user_id = ["sharedIdSystem"] analytics = ["atsAnalyticsAdapter"] @@ -697,7 +701,7 @@ ts prebid client 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 `[auction.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 0000000000..3782859d85 --- /dev/null +++ b/docs/guide/configuration-rules.md @@ -0,0 +1,318 @@ +# 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 type + +Everything Trusted Server can switch on is a module, selected the same way: +the Edge Cookie identity, the location and device lookups, the permission +signals, the demand sources in an auction, the ad server that picks the +winner, and the page integrations. + +```toml +[] +module = "" # modules = [...] where several run + +[.] # only when the selected name has settings +setting = "value" +``` + +1. **The type is the job.** Each type is one top-level table, named for what + its modules do: `ec`, `geo`, `device`, `permission-signal`, `demand` and + `ad-server`, and the section of each module type, such as `cmp` or `tag`. +2. **The selector chooses what runs.** A type that runs one module takes + `module`, a string. A type that runs several takes `modules`, a list. +3. **`[.]` holds the settings.** A name 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. A demand source always has one, naming the + implementation by its module path, such as + `implementation = "auction.prebid-server"`, because `demand` is not the + type its implementations are named under. That is how two Prebid Servers + run side by side. +5. **A name is parts joined by `.`,** each of lower case letters, digits, `_` + or `-`. A module from a crate is named by its folder below `crates/`, and + may be written in full, as `permission-signal.gpc`, or with its section's + type folder left off. Core's own modules, such as `hmac`, take bare names. + A `[demand]` or `[ad-server]` name is snake_case, because it may be a label + of your own. +6. **Secrets are key names.** A secret setting holds the name of a key in + `trusted_server_secrets`, never the secret itself. + +| Type | Runs | Selector | Implementations in this repository | +| ------------------- | ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------- | +| `ec` | one | `module`, a string | `hmac`, `host_signals`, `client_fixed` (demonstration builds), or an integration that supplies identity | +| `geo` | one | `module`, a string | `platform`, `none`, or an integration that supplies location | +| `device` | one | `module`, a string | `builtin` (the default), `fastly`, or an integration that supplies device signals | +| `permission-signal` | several, in order | `modules`, a list | `gpc`, `gpp`, `us-privacy`, `tcf` | +| `demand` | several | `modules`, a list | `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` | +| `ad-server` | one | `module`, a string | `mock` | +| `cmp` | one | `module`, a string | `didomi`, `sourcepoint`, `osano` | +| `tag` | several | `modules`, a list | `google-tag-manager` | +| `ad-tag` | several | `modules`, a list | `google`, `google.diagnostics` | +| `bot-protection` | one | `module`, a string | `datadome` | +| `identity` | one | `module`, a string | `lockr` | +| `audience` | one | `module`, a string | `permutive` | +| `framework` | one | `module`, a string | `nextjs` | +| `auction` | several | `modules`, a list | `prebid`, `testing.testlight` | +| `proxy` | several | `modules`, a list | `js_asset_proxy` | + +`auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` and `ad-server.mock` supply implementations +only. No module section selects them. + +A module that an integration supplies needs that integration selected in the +section of its type 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 the selector, where the setting +belongs to the job rather than to one name. `[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 the selector out + +| Type | With no `module` or `modules` 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 module runs, in the order shown above | +| `demand` | no demand source is called | +| `ad-server` | the highest bid wins, with no ad server | +| a module type's section, such as `cmp` or `tag` | the section is refused, so remove the whole section when none of its modules 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 selector does not name. A block + left behind after a module is switched off is caught, rather than sitting + unused and misleading the next reader. +- A selector entry, or an `implementation` line, that names an + implementation this build does not have. The message lists the ones it does. +- A selected module that needs a setting its table does not give, such as + `hmac` with no `passphrase`. +- A setting the selected name does not know. Every module and provider + rejects unknown keys, so + a misspelled setting fails instead of being ignored. +- A name that is not a module name, or a name selected twice. +- A key in a type's table that is neither the selector, one of that type's + own settings, nor a named settings table. +- A `demand` or `ad-server` 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]`, `[ad-server]` and + `[auction.bidders]`. That covers unselected tables, names that are not + a module name, an implementation this build does not have, endpoint scheme and + host, timeouts, routing modes, notification bounds, a bidder route naming a + demand source `[demand] modules` does not select, and any setting the + chosen implementation rejects. +- Every selected module's own settings, and the refusal of a table for a + module its section does not select, of a section that selects nothing, and + of the removed `[integrations]` and `[integration]` tables. +- 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 modules `[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 module is matched to the type that selected + it, and where a name a section selects that no builder in this build + supplies is refused. A module declaring an identity or device module 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 module needs when no geo module 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 select nothing + +The other tables configure Trusted Server itself rather than choose what runs. +They keep their own keys and have no selector. + +| 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 module 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 what runs is +a change to one selector 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 module plugs in through one +registration and inherits the checks above without writing them again. Core +code does not name any vendor, so adding a module does not mean editing core, +and a vendor's crate can supply its own module 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"] + +[ad-tag] +modules = ["google"] + +[auction.prebid] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +timeout_ms = 1500 # the browser's Prebid timeout + +[ad-tag.google] +gam_attribution_enabled = true + +[ec] +module = "hmac" +ec_store = "ec_identity_store" + +[ec.hmac] +passphrase = "ec_passphrase" # key name + +[geo] +module = "platform" + +[device] +module = "builtin" + +[permission-signal] +modules = ["gpc", "gpp", "us-privacy", "tcf"] + +[demand] +modules = ["pbs_main"] + +[demand.pbs_main] +implementation = "auction.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" + +[ad-server] +module = "mock" + +[ad-server.mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 + +[auction] +modules = ["prebid"] +enabled = true +timeout_ms = 2000 # the whole auction + +[auction.bidders.example-server] +module = "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] +modules = ["pbs_main", "pbs_house"] + +[demand.pbs_main] +implementation = "auction.prebid-server" +endpoint = "https://prebid.example.com/openrtb2/auction" + +[demand.pbs_house] +implementation = "auction.prebid-server" +endpoint = "https://house.example.com/openrtb2/auction" +``` + +## Moving from the previous layout + +| Previous | Now | +| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| `[integrations.]` with `enabled = true` | the module in the section of its type, and `[.]` only for settings | +| `[ec.providers.]` | `[ec.]` | +| `[permission_signal] sources` | `[permission-signal] modules` | +| `host-signals`, `client-fixed`, `gpp-sale-opt-out`, `gpp_sale_opt_out`, `us_privacy` | `host_signals`, `client_fixed`, `gpp`, `us-privacy` | +| `[auction.providers.]` with `protocol`, `profile` and `profile_config` | `[demand] modules` and `[demand.]`, with `implementation` and the settings flat in the table | +| `profile = "standard"` | `implementation = "auction-protocol.openrtb"` | +| `profile = "prebid-server"` and `profile = "aps"` | `implementation = "auction.prebid-server"` and `implementation = "auction.aps"` | +| `[auction] mediator = "adserver_mock"` and `[integrations.adserver_mock]` | `[ad-server] module = "mock"` and `[ad-server.mock]` | +| `[integrations.aps] rendering_mode` | `rendering_mode` in the `[demand.]` table of the `auction.aps` implementation | +| `[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 `ad-server` +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 module + +A module 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 81d936ec2c..0f4e3f830c 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 module, selected the same way, +with `module`, or `modules` where several run. Read +[Configuration Rules](/guide/configuration-rules) first. It is short, and it +is the pattern every section below follows: + +```toml +[] +module = "" # modules = [...] where several run + +[.] # only when the selected name 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] +module = "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__MODULE=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] module = "hmac"` +- `ec.host_signals.passphrase`, when `[ec] module = "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 +- `bot-protection.datadome.server_side_key_secret_name`, when protection is enabled +- `bot-protection.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 @@ -91,26 +109,26 @@ diff; it does not move inline values to a secret store. The following table records the independent lifecycle, key-identity, serialization, runtime, and secret axes for every exceptional field: -| Path | Lifecycle | Key identity | Serialization | Runtime | Secret handling | -| ------------------------------------------------------------ | ---------- | ----------------------------------- | ------------- | -------------------- | ------------------------ | -| `AssetOriginAuth.s3_sig_v4` | deprecated | alias of `AssetOriginAuth.s3_sigv4` | skipped | deserialization only | none | -| `DataDomeConfig.server_side_key_secret_name` | canonical | canonical | serialized | active | store resolved | -| `DataDomeConfig.server_side_key_secret_store` | deprecated | canonical | skipped | normalized away | none | -| `DataDomeProtectionTestBypassConfig.credential_secret_name` | canonical | canonical | serialized | active | store resolved | -| `DataDomeProtectionTestBypassConfig.credential_secret_store` | deprecated | canonical | skipped | normalized away | none | -| `Ec.passphrase` | canonical | canonical | serialized | active | store resolved | -| `EcPartner.api_token` | canonical | canonical | serialized | active | store resolved | -| `EcPartner.ts_pull_token` | canonical | canonical | serialized | active | store resolved | -| `Handler.password` | canonical | canonical | serialized | active | store resolved | -| `Publisher.proxy_secret` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.access_key_id` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.secret_access_key` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.secret_store` | deprecated | canonical | skipped | normalized away | none | -| `S3SigV4AuthConfig.session_token` | canonical | canonical | serialized | active | store resolved | -| `TinybirdSettings.access_token_secret` | deprecated | canonical | skipped | normalized away | accepted, then discarded | -| `TinybirdSettings.auction_token_secret` | canonical | canonical | serialized | active | store resolved | -| `TinybirdSettings.secret_store` | deprecated | canonical | skipped | normalized away | none | -| `TrustedClientIpConfig.shared_secret` | canonical | canonical | serialized | active | deliberately inline | +| Path | Lifecycle | Key identity | Serialization | Runtime | Secret handling | +| ------------------------------------------------------------ | ---------- | ----------------------------------- | ------------- | ----------------------- | ------------------------ | +| `AssetOriginAuth.s3_sig_v4` | deprecated | alias of `AssetOriginAuth.s3_sigv4` | skipped | deserialization only | none | +| `DataDomeConfig.server_side_key_secret_name` | canonical | canonical | serialized | active | store resolved | +| `DataDomeConfig.server_side_key_secret_store` | deprecated | canonical | serialized | discarded by the module | none | +| `DataDomeProtectionTestBypassConfig.credential_secret_name` | canonical | canonical | serialized | active | store resolved | +| `DataDomeProtectionTestBypassConfig.credential_secret_store` | deprecated | canonical | serialized | discarded by the module | none | +| `Ec.passphrase` | canonical | canonical | serialized | active | store resolved | +| `EcPartner.api_token` | canonical | canonical | serialized | active | store resolved | +| `EcPartner.ts_pull_token` | canonical | canonical | serialized | active | store resolved | +| `Handler.password` | canonical | canonical | serialized | active | store resolved | +| `Publisher.proxy_secret` | canonical | canonical | serialized | active | store resolved | +| `S3SigV4AuthConfig.access_key_id` | canonical | canonical | serialized | active | store resolved | +| `S3SigV4AuthConfig.secret_access_key` | canonical | canonical | serialized | active | store resolved | +| `S3SigV4AuthConfig.secret_store` | deprecated | canonical | skipped | normalized away | none | +| `S3SigV4AuthConfig.session_token` | canonical | canonical | serialized | active | store resolved | +| `TinybirdSettings.access_token_secret` | deprecated | canonical | skipped | normalized away | accepted, then discarded | +| `TinybirdSettings.auction_token_secret` | canonical | canonical | serialized | active | store resolved | +| `TinybirdSettings.secret_store` | deprecated | canonical | skipped | normalized away | none | +| `TrustedClientIpConfig.shared_secret` | canonical | canonical | serialized | active | deliberately inline | Prepare an initial reference-based deployment in this order: @@ -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 module'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,46 @@ 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 select what runs, with `module` where one runs and +`modules` where several run, and each gives every selected name its own +`[.]` settings table, as +[Configuration Rules](/guide/configuration-rules) describes. + +| Section | Selects | Purpose | +| ----------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------- | +| `[ad-server]` | one module | The ad server that picks the winner | +| `[auction]` | several modules | Auction orchestration, bidder routes, and the modules the auction runs, `prebid` among them | +| `[cache]` | nothing | Static and rehosted asset cache policy | +| `[consent]` | nothing | Consent interpretation, forwarding, and conflict resolution | +| `[creative_opportunities]` | nothing | Server-side page ad opportunities and templates | +| `[debug]` | nothing | Explicit non-production diagnostics | +| `[demand]` | several modules | The auction's demand sources | +| `[device]` | one module | Device classification | +| `[ec]` | one module | Edge Cookie identity, persistence, and partner sync | +| `[geo]` | one module | Which module resolves location, if any | +| `[[handlers]]` | nothing | Ordered HTTP Basic-auth rules | +| `[image_optimizer]` | nothing | Reusable Fastly Image Optimizer profiles | +| `[inspect]` | nothing | What the configuration page at `/_ts/config` shows | +| `[permission-signal]` | several modules | Which permission signals are acted on, in order | +| `[proxy]` | several modules | Proxy allowlist, TLS policy, asset routes, and the first-party script proxy module | +| `[publisher]` | nothing | Publisher domain, origin, and proxy signing key | +| `[request_signing]` | nothing | Outbound Ed25519 request signing and management-store IDs | +| `[response_headers]` | nothing | Headers added to Trusted Server responses | +| `[rewrite]` | nothing | First-party URL rewrite exclusions | +| `[tester_cookie]` | nothing | Optional tester-cookie endpoints | +| `[tinybird]` | nothing | Direct Tinybird auction telemetry | +| `[trusted_client_ip]` | nothing | Authenticated front-door client-IP forwarding | +| `[]`, such as `[cmp]`, `[tag]`, `[ad-tag]`, `[bot-protection]`, `[identity]`, `[audience]` or `[framework]` | one module or several | The section of a module type, selecting the modules of that type that run | ## 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 +256,9 @@ origin_url = "https://origin.publisher.com" proxy_secret = "publisher_proxy_secret" [ec] +module = "hmac" + +[ec.hmac] passphrase = "ec_passphrase" [request_signing] @@ -232,8 +266,7 @@ enabled = true config_store_id = "01GXXX" secret_store_id = "01GYYY" -[integrations.prebid] -enabled = true +[auction.prebid] client_side_bidders = ["example-browser-bidder"] external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" @@ -241,21 +274,22 @@ external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" allowed_domains = ["assets.example.com"] [auction] +modules = ["prebid"] enabled = true timeout_ms = 2000 -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +modules = ["pbs_main"] + +[demand.pbs_main] +implementation = "auction.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" +module = "pbs_main" ``` ## Detailed Reference @@ -288,20 +322,21 @@ 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`. +- Selected names are snake_case, so a settings 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 setting in one of those tables 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 `modules` list is an array, so it cannot be overridden +this way. ## Publisher Configuration @@ -440,7 +475,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 +499,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. @@ -624,6 +659,53 @@ enabled = true TRUSTED_SERVER__TESTER_COOKIE__ENABLED=true ``` +## Inspect Configuration + +A deployment publishes the settings it is running at +[`/_ts/config`](/guide/api-reference#get-ts-config-and-get-ts-config-json), to +anyone who asks. Every secret is masked, and so is every value that is +sensitive by default. This section is where a publisher changes those +defaults. + +### `[inspect]` + +| Field | Type | Required | Description | +| -------- | ---------------- | -------- | --------------------------------------------------------------------------------------- | +| `config` | Boolean | No | Whether the configuration is published. Default `true`. `false` answers `404 Not Found` | +| `show` | Array of strings | No | Values masked by default that are shown instead. A secret cannot be shown | +| `hide` | Array of strings | No | Values masked as well as the defaults | + +Each entry of `show` and `hide` is a path pattern, being keys joined by `.`, +with `[]` for every element of a list and `[N]` for one, such as +`publisher.origin_url`, `proxy.asset_routes[].origin_url` or +`handlers[0].path`. A pattern names the values at exactly its own depth. + +A masked value shows as `XXXX`, and the page lists every masked path. + +| What | Masked | Can `show` reveal it | +| ---------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------- | +| A secret, meaning a value the settings loader fills from the secret store | Always | No | +| `publisher.origin_url` and `publisher.origin_host_header_override` | By default | Yes | +| `proxy.asset_routes[].origin_url` | By default | Yes | +| `handlers[].path` and `handlers[].username` | By default | Yes | +| `ec.ec_store`, `request_signing.config_store_id`, `request_signing.secret_store_id` and `auction.creative_store` | By default | Yes | +| Anything `hide` names | When named | It is the publisher's own choice | + +A pattern that would not be honored as written refuses the configuration, +both when a deployment is validated and when the settings load. That is a +pattern that matches no value, a `show` that names a secret, a pattern another +pattern of the same list already covers, a `show` that a `hide` covers, a +pattern that changes nothing the page shows, and any pattern at all beside +`config = false`. + +**Example**: + +```toml +[inspect] +show = ["publisher.origin_url"] +hide = ["response_headers"] +``` + ## EC Configuration Settings for generating privacy-preserving Edge Cookie identifiers. The `ec_store` KV store is the only KV-backed EC lifecycle store; it holds identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones. Live consent is interpreted from request cookies, headers, geolocation, and policy defaults, not separate KV persistence. @@ -636,17 +718,48 @@ Legacy consent-store records are not read or migrated into `ec.ec_store`. Their ### `[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 | +| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `module` | String or null | No | Name of the active Edge Cookie module: `"hmac"` (built-in), `"host_signals"` (opt-in), `"none"` (explicitly stateless), or the name of a module a crate outside core supplies. Omit to run statelessly with no Edge Cookie. The `"client_fixed"` demonstration module 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 module that has settings is configured in its own `[ec.]` table, and the `module` selector names which table is active. A table may set `implementation = ""` to say which module it configures, which makes the table name a label of your choosing, so `module = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in module under a name that means something to your deployment. A module from a crate is named by its folder below `crates/`, so it may be written in full, as `edgecookie.`, or with that type folder left off, and core's own modules such as `hmac` take bare names. A name is parts joined by `.`, each of lower case letters, digits, `_` or `-`. + +A module has a table only when it has settings of its own. Both modules 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 module 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 module, so they sit directly in `[ec]` whichever module +is selected. -| 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 | +A crate outside core declares each module it supplies under a name, and +`module` selects it by that name. The crate's own module has to be selected +in the section of its type as well, because only a selected module runs. A +name written in full has more than one part, so a module selected that way +takes its settings under a label, as `[ec.primary]` with +`implementation = ""`. + +### `[ec.hmac]` + +The built-in HMAC-over-client-IP module, 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 module 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. @@ -661,9 +774,12 @@ outbound pull sync, but cannot authenticate to those inbound APIs. ```toml [ec] -passphrase = "ec_passphrase" +module = "hmac" ec_store = "ec_identity_store" +[ec.hmac] +passphrase = "ec_passphrase" + [[ec.partners]] name = "Mocktioneer SSP" source_domain = "mocktioneer.example" @@ -675,25 +791,132 @@ bidstream_enabled = true **Environment Override**: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase +TRUSTED_SERVER__EC__MODULE=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 module selection changes take effect when a new configuration is pushed, not per request. + ### Field Details -#### `passphrase` +#### `module` + +**Purpose**: Names the active Edge Cookie module. Omit to run statelessly with no Edge Cookie. + +**Validation**: Application startup fails if the name is not a module name (parts joined by `.`, each of lower case letters, digits, `_` or `-`), if it names a key the `[ec]` section reads as its own setting, if the selected module has no `[ec.]` table where it needs one, if it names a module 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]`. -**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation. +#### `hmac.passphrase` + +**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation, read when `module = "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 module selection. These signals serve identifier gating and bot detection, not bid enrichment. + +### `[device]` + +| Field | Type | Required | Description | +| -------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `module` | String or null | No | Name of the device-detection module: `builtin` (the default, User-Agent only, no host-specific call), `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers, or the name of a module a crate outside core supplies | + +The default `builtin` module 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, and `[device]` holds no module settings table. A module from a crate at `crates/device/` is written `` or `device.`, and the crate's own module has to be selected in the section of its type as well. Naming a module this deployment does not run fails at startup, with the device modules it does run. + +**Example**: + +```toml +[device] +module = "builtin" # or "fastly" to add TLS and HTTP/2 evidence +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__DEVICE__MODULE=builtin +``` + +## Geo Configuration + +Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie module selection. The resolved country also feeds the [permission model](/guide/permission-model). + +### `[geo]` + +| Field | Type | Required | Description | +| ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `module` | String or null | No | Name of the geo module: `platform` to use the host's own geo lookup, `none` (or omit it) to resolve no location and make no host geo call, or the name of a module a crate outside core supplies | +| `assume_single_jurisdiction` | Boolean | See description | With no geo module, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie module without a geo module acknowledges that by setting this to `true`. | + +`assume_single_jurisdiction` is a setting of the job rather than of one +module, so it sits directly in `[geo]`. + +No module is the default, so a default deployment is not tied to any host geo service. A module from a crate at `crates/geo/` is written `` or `geo.`, and the crate's own module has to be selected in the section of its type as well. Naming a module this deployment does not run fails at startup, with the geo modules it does run. 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] +module = "platform" +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__GEO__MODULE=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 module with an +opinion decides. + +### `[permission-signal]` + +| Field | Type | Required | Description | +| --------- | ------------- | -------- | ---------------------------------------------------------------------------------------------------------- | +| `modules` | Array[String] | No | The modules to act on, in order. Omit it to act on every module this build links, in the order shown below | + +The modules that ship are `gpc` (the `Sec-GPC` request header), +`gpp` (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. -- Empty string +A module 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] +modules = ["gpc", "gpp", "us-privacy", "tcf"] +``` + +See [Permission Signals](/guide/permission-signals) for what each module +reads and how to add a scheme. + +## Module Permissions + +A module advertises the technical permissions its data use requires, and Trusted Server runs the module 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 @@ -928,7 +1151,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). @@ -937,7 +1160,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. @@ -957,7 +1180,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. @@ -1082,6 +1305,7 @@ Controls first-party proxy security settings and path-based asset routes. | `allowed_domains` | Array[String] | No (default: `[]`) | Hosts permitted for signing, initial fetches, and redirects | | `certificate_check` | Boolean | No (default: `true`) | Verify TLS certificates when proxying HTTPS origins | | `asset_routes` | Array[Table] | No (default: `[]`) | Path prefixes proxied directly to configured origins | +| `modules` | Array[String] | No (default: `[]`) | The proxy modules that run, such as `js_asset_proxy` | **Example**: @@ -1102,7 +1326,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 `auction.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. @@ -1115,8 +1339,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`. @@ -1454,75 +1678,72 @@ 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 | - -### Ad Server Mock Integration - -**Section**: `[integrations.adserver_mock]` - -This integration is the optional auction mediator selected by -`auction.mediator = "adserver_mock"`; it is not an OpenRTB provider. - -| Field | Type | Default | Contract | -| ---------------------- | -------------- | -------- | ------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable mediator registration | -| `endpoint` | URL | Required | Mediation service URL | -| `timeout_ms` | Integer | `500` | `1..=60000` milliseconds | -| `price_floor` | Number or null | `null` | Optional minimum accepted CPM | -| `context_query_params` | Object | `{}` | Maps admitted auction-context keys to query names | - -### APS Browser Integration - -**Section**: `[integrations.aps]` - -| Field | Type | Default | Contract | -| ---------------- | ------- | ------------------ | -------------------------------------- | -| `enabled` | Boolean | `false` | Enable browser-side APS behavior | -| `rendering_mode` | String | `"trusted_server"` | `trusted_server` or `publisher_native` | - -Server endpoint, timeout, account, inventory, and debug settings belong to an -`aps` provider and its `profile_config`, not this browser section. See -[APS](/guide/integrations/aps). +A page integration is a module, selected in the section of its type, and +each one that has settings gets its own `[
.]` table. A +module's name is `.`, which is the path under `crates/` of the +crate the module lives in. There is no `enabled` flag and no `[integration]` +table, because a module no section selects does not run, and an `enabled` +key left in a module's table refuses startup. The full rule set is in +[Configuration Rules](/guide/configuration-rules). Every module that deploy +validation knows is listed below. + +| Section | Reference | +| ----------------------------- | ------------------------------------------------------------------ | +| `[bot-protection.datadome]` | [DataDome](/guide/integrations/datadome) | +| `[cmp.didomi]` | [Didomi](/guide/integrations/didomi) | +| `[tag.google-tag-manager]` | [Google Tag Manager](/guide/integrations/google_tag_manager) | +| `[ad-tag.google]` | [GPT](/guide/integrations/gpt) | +| `[ad-tag.google.diagnostics]` | [GPT diagnostics](/guide/integrations/gpt-diagnostics) | +| `[proxy.js_asset_proxy]` | [JS Asset Proxy](#js-asset-proxy-integration) (no dedicated guide) | +| `[identity.lockr]` | [lockr](/guide/integrations/lockr) | +| `[framework.nextjs]` | [Next.js](/guide/integrations/nextjs) | +| `[cmp.osano]` | [Osano](/guide/integrations/osano) | +| `[audience.permutive]` | [Permutive](/guide/integrations/permutive) | +| `[auction.prebid]` | [Prebid](/guide/integrations/prebid) | +| `[cmp.sourcepoint]` | [Sourcepoint](/guide/integrations/sourcepoint) | +| `[auction.testing.testlight]` | [Testlight](/guide/integrations/testlight) | + +### Selecting the modules that run + +```toml +[auction] +modules = ["prebid"] + +[ad-tag] +modules = ["google"] + +[framework] +module = "nextjs" +``` + +The modules this repository ships, by section, are `[cmp]` `didomi`, +`sourcepoint` and `osano`; `[tag]` `google-tag-manager`; `[ad-tag]` `google` +and `google.diagnostics`; `[bot-protection]` `datadome`; `[identity]` +`lockr`; `[audience]` `permutive`; `[framework]` `nextjs`; `[auction]` +`prebid` and `testing.testlight`; and `[proxy]` `js_asset_proxy`. A name +this build does not have refuses startup, and the message lists the ones it +does. A `[
.]` table for a module its section does not select +refuses startup too, as does a section that selects nothing, so a table left +behind after a module is switched off is caught rather than sitting unread. + +`auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` and +`ad-server.mock` supply demand and ad server implementations only. They are +not page integrations, so a section that names one refuses startup like any +name no module supplies. Their settings live in `[demand.]` and +`[ad-server.]`. + +The sections below give each module's settings, and the integration guides +describe what each one does. ### DataDome Integration -**Section**: `[integrations.datadome]` +**Section**: `[bot-protection.datadome]` The [DataDome guide](/guide/integrations/datadome) explains request behavior and exclusion-rule syntax. This table covers every canonical top-level field: | Field | Type | Default | Contract | | -------------------------------------- | -------------- | -------------------------------- | --------------------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable DataDome registration | | `sdk_origin` | URL | `https://js.datadome.co` | Browser SDK origin | | `api_origin` | URL | `https://api-js.datadome.co` | Browser signal API origin | | `cache_ttl_seconds` | Integer | `3600` | `60..=86400` seconds | @@ -1562,11 +1783,10 @@ to new configurations. ### Didomi Integration -**Section**: `[integrations.didomi]` +**Section**: `[cmp.didomi]` | Field | Type | Default | Contract | | ------------ | -------------- | -------------------------------- | ------------------------------------------------------------------- | -| `enabled` | Boolean | `true` | Enable the reverse proxy when the section exists | | `proxy_path` | String or null | `/integrations/didomi/consent` | No trailing slash, repeated slash, dot segment, or unsafe character | | `sdk_origin` | URL | `https://sdk.privacy-center.org` | SDK upstream | | `api_origin` | URL | `https://api.privacy-center.org` | API upstream | @@ -1575,11 +1795,10 @@ See [Didomi](/guide/integrations/didomi) for the routed endpoint shapes. ### Google Tag Manager Integration -**Section**: `[integrations.google_tag_manager]` +**Section**: `[tag.google-tag-manager]` | Field | Type | Default | Contract | | ---------------------- | ------- | ---------------------------------- | --------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable tag-gateway routes and rewriting | | `container_id` | String | Required | `GTM-` followed by 4–20 uppercase letters or digits | | `upstream_url` | URL | `https://www.googletagmanager.com` | Script upstream | | `cache_max_age` | Integer | `900` | `60..=86400` seconds | @@ -1589,11 +1808,10 @@ See [Google Tag Manager](/guide/integrations/google_tag_manager). ### GPT Integration -**Section**: `[integrations.gpt]` +**Section**: `[ad-tag.google]` | Field | Type | Default | Contract | | ------------------------- | -------------- | ----------------------- | ------------------------------------------------------ | -| `enabled` | Boolean | `true` | Enable GPT when the section exists | | `gam_attribution_enabled` | Boolean | `false` | Add page-level `ts=true` targeting | | `script_url` | URL | Google's secure GPT URL | Bootstrap source | | `cache_ttl_seconds` | Integer | `3600` | `60..=86400` seconds | @@ -1604,15 +1822,17 @@ See [GPT](/guide/integrations/gpt). ### GPT Diagnostics Integration -**Section**: `[integrations.gpt_diagnostics]` +**Section**: `[ad-tag.google.diagnostics]` -The only field is `enabled`, a Boolean that defaults to `false`. When enabled, -the standalone diagnostics tag is available, but individual browser sessions -still require the activation flow in [GPT diagnostics](/guide/integrations/gpt-diagnostics). +The module has nothing to set, so naming `google.diagnostics` in +`[ad-tag] modules` is the whole configuration and it needs no table. Once it +is selected the standalone diagnostics tag is available, but individual +browser sessions still require the activation flow in +[GPT diagnostics](/guide/integrations/gpt-diagnostics). ### JS Asset Proxy Integration -**Section**: `[integrations.js_asset_proxy]` +**Section**: `[proxy.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 @@ -1623,11 +1843,10 @@ guide; the registered routes appear in the | Field | Type | Default | Contract | | ------------------- | ------- | ------- | --------------------------------------------- | -| `enabled` | Boolean | `false` | Enable asset proxying and HTML rewriting | | `cache_ttl_seconds` | Integer | None | Optional downstream cache TTL for every asset | -| `assets` | Array | `[]` | Asset mappings; required when enabled | +| `assets` | Array | `[]` | Asset mappings; at least one is required | -Each `[[integrations.js_asset_proxy.assets]]` entry: +Each `[[proxy.js_asset_proxy.assets]]` entry: | Field | Type | Default | Contract | | ------------------- | ------- | --------- | --------------------------------------------------------- | @@ -1638,37 +1857,36 @@ Each `[[integrations.js_asset_proxy.assets]]` entry: ### lockr Integration -**Section**: `[integrations.lockr]` +**Section**: `[identity.lockr]` -| Field | Type | Default | Contract | -| ------------------- | --------------- | --------------------------------------------------- | ------------------------------------ | -| `enabled` | Boolean | `true` | Enable lockr when the section exists | -| `app_id` | String | Required | Nonempty lockr application ID | -| `api_endpoint` | URL | `https://identity.loc.kr` | API origin | -| `sdk_url` | URL | `https://aim.loc.kr/identity-lockr-trust-server.js` | SDK source | -| `cache_ttl_seconds` | Integer | `3600` | `60..=86400` seconds | -| `rewrite_sdk` | Boolean | `true` | Rewrite matching lockr SDK URLs | -| `rewrite_sdk_host` | Boolean or null | `null` | Deprecated compatibility input | -| `origin_override` | URL or null | `null` | Optional upstream `Origin` override | +| Field | Type | Default | Contract | +| ------------------- | --------------- | --------------------------------------------------- | ----------------------------------- | +| `app_id` | String | Required | Nonempty lockr application ID | +| `api_endpoint` | URL | `https://identity.loc.kr` | API origin | +| `sdk_url` | URL | `https://aim.loc.kr/identity-lockr-trust-server.js` | SDK source | +| `cache_ttl_seconds` | Integer | `3600` | `60..=86400` seconds | +| `rewrite_sdk` | Boolean | `true` | Rewrite matching lockr SDK URLs | +| `rewrite_sdk_host` | Boolean or null | `null` | Deprecated compatibility input | +| `origin_override` | URL or null | `null` | Optional upstream `Origin` override | 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]`. +`[auction.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 = "auction.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 | +| `timeout_ms` | Integer | `1000` | Browser Prebid.js timeout; independent of every demand source timeout | +| `debug` | Boolean | `false` | Browser Prebid.js debug flag; independent of a demand source's debug | | `client_side_bidders` | Array[String] | `[]` | Bidders kept on native browser adapters | | `excluded_gam_ad_unit_path_suffixes` | Array[String] | `[]` | GAM suffixes excluded from Trusted Server refresh auctions | | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | Publisher Prebid script paths intercepted by Trusted Server | -| `external_bundle_url` | String | Required when enabled | HTTPS publisher-specific Prebid.js bundle URL | +| `external_bundle_url` | String | Required | HTTPS publisher-specific Prebid.js bundle URL | | `external_bundle_sha256` / `*_sri` | String | `None` | Optional bundle integrity and cache metadata | | `bundle.modules.bidder` | Array[String] | Required and non-empty | Exact bidder module stems used by `ts prebid client` | | `bundle.modules.user_id` | Array[String] | Curated preset when omitted | Exact User ID module stems used by `ts prebid client` | @@ -1677,24 +1895,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 +`[auction.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 +[auction] +modules = ["prebid"] + +[auction.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]] +[[auction.prebid.managed_user_ids]] name = "sharedId" -[integrations.prebid.managed_user_ids.storage] +[auction.prebid.managed_user_ids.storage] type = "cookie" name = "_sharedid" expires = 15 @@ -1703,52 +1923,51 @@ refresh_in_seconds = 1800 [proxy] allowed_domains = ["assets.example.com"] -[integrations.prebid.bundle.modules] +[auction.prebid.bundle.modules] bidder = ["rubiconBidAdapter"] user_id = ["sharedIdSystem"] analytics = ["atsAnalyticsAdapter"] -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand] +modules = ["pbs_main"] + +[demand.pbs_main] +implementation = "auction.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" +module = "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__AUCTION__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 `modules` +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 `[[auction.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 @@ -1765,7 +1984,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 +`[auction.prebid.bundle.modules].user_id`, or omit that list to take the generator's default preset. `ts prebid client` 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 @@ -1778,8 +1997,7 @@ unchanged. Persisting a resolved ID into the Edge Cookie identity graph additionally requires a matching `[[ec.partners]]` entry whose `source_domain` equals the -module's OpenRTB EID source, and, when a TCF signal is present, consent to -TCF Purpose 1 and Purpose 4. +module's OpenRTB EID source, and the storage and personalised-ad permissions. `managed_user_ids` is an array of tables, so it cannot be set through a `TRUSTED_SERVER__` environment variable; the scalar overlay only replaces leaves @@ -1799,33 +2017,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 `auction.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**: `[framework.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 +[framework] +module = "nextjs" + +[framework.nextjs] rewrite_attributes = ["href", "link", "url", "src"] max_combined_payload_bytes = 10485760 ``` @@ -1833,42 +2052,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__FRAMEWORK__NEXTJS__MAX_COMBINED_PAYLOAD_BYTES=10485760 ``` Edit `rewrite_attributes` in TOML because the overlay cannot replace arrays. ### Osano Integration -**Section**: `[integrations.osano]` +**Section**: `[cmp.osano]` -| Field | Type | Default | Description | -| --------- | ------- | ------- | --------------------------------------- | -| `enabled` | Boolean | `false` | Enable the Osano browser consent mirror | +Osano has nothing to set, so `[cmp] module = "osano"` is the whole +configuration and it needs no table. **Example**: ```toml -[integrations.osano] -enabled = true -``` - -**Environment Override**: - -```bash -TRUSTED_SERVER__INTEGRATIONS__OSANO__ENABLED=true +[cmp] +module = "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**: `[audience.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 | @@ -1880,8 +2090,10 @@ The Osano mirror runs in the browser, so consent cookies it writes are available **Example**: ```toml -[integrations.permutive] -enabled = true +[audience] +module = "permutive" + +[audience.permutive] organization_id = "org-12345" workspace_id = "ws-67890" project_id = "proj-abcde" @@ -1893,11 +2105,10 @@ rewrite_sdk = true ### Sourcepoint Integration -**Section**: `[integrations.sourcepoint]` +**Section**: `[cmp.sourcepoint]` | Field | Type | Default | Contract | | ------------------- | -------------- | ------------------------------ | ----------------------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable Sourcepoint proxying and rewriting | | `rewrite_sdk` | Boolean | `true` | Rewrite matching Sourcepoint URLs | | `cdn_origin` | URL | `https://cdn.privacy-mgmt.com` | HTTP(S) URL whose host is exactly `cdn.privacy-mgmt.com` | | `auth_cookie_name` | String or null | `null` | 1–64 letters, digits, `_`, or `-`; built-in cookies need no entry | @@ -1907,11 +2118,10 @@ See [Sourcepoint](/guide/integrations/sourcepoint). ### Testlight Integration -**Section**: `[integrations.testlight]` +**Section**: `[auction.testing.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 | @@ -1920,8 +2130,10 @@ See [Sourcepoint](/guide/integrations/sourcepoint). **Example**: ```toml -[integrations.testlight] -enabled = true +[auction] +modules = ["testing.testlight"] + +[auction.testing.testlight] endpoint = "https://testlight.example/openrtb2/auction" timeout_ms = 1500 rewrite_scripts = true @@ -1929,10 +2141,12 @@ 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, `[ad-server]` 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] modules` selects the page modules the auction runs, such as +`prebid`, and never a demand source or the ad server. ### `[auction]` @@ -1942,9 +2156,9 @@ 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 | +| `modules` | Array | `[]` | The page modules the auction runs, such as `prebid` | Creative markup delivered by `POST /auction` and the publisher SSAT/page-bids path is processed by two independent passes. With `sanitize_creatives = true` @@ -1966,7 +2180,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. @@ -1992,42 +2206,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`. Demand source names +that carried a hyphen, such as `pbs-main`, become snake_case, such as `pbs_main`, +and so does every `[auction.bidders.] module` 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 +`[auction.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] modules` 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. +Every demand table carries an `implementation` line naming the +implementation by its module path, such as `auction.prebid-server`, which is +how two Prebid Servers run side by side under names of their own. **Example**: @@ -2037,108 +2260,134 @@ 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] +modules = ["pbs_main", "aps_main"] + +[demand.pbs_main] +implementation = "auction.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 = "auction.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" +module = "pbs_main" -[integrations.adserver_mock] -enabled = true -endpoint = "https://mediator.example.com/mediate" +[ad-server] +module = "mock" + +[ad-server.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 | +| -------------------------- | ------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `auction-protocol.openrtb` | the auction budget | yes | `request_ext`, `imp_ext` | +| `auction.prebid-server` | 1000 ms | no | `debug`, `test_mode`, `debug_query_params`, `consent_forwarding`, `bid_param_overrides`, `bid_param_zone_overrides`, `bid_param_override_rules` | +| `auction.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. +`auction.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 + +`[ad-server] module` names the one ad server that picks the winner, as a +string rather than a list, and `[ad-server.]` 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 `ad-server.mock`, written +`mock` in `[ad-server]`, 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 +[ad-server] +module = "mock" + +[ad-server.mock] +endpoint = "https://adserver.example.com/decide" +timeout_ms = 500 + +[ad-server.mock.context_query_params] +example_segments = "segments" +``` + +The word mediator is gone. It is "ad server" in prose and `ad-server` 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] modules` 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 `auction-protocol.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"] ``` @@ -2149,51 +2398,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]`, `[ad-server]` 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 selector list is an array, so `[demand] modules` and a section's +`modules` cannot be changed by an overlay. Edit the TOML, then +validate and push. + ## Creative Opportunities Configuration ### `[creative_opportunities]` @@ -2236,7 +2491,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". @@ -2246,7 +2501,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 }] ``` @@ -2480,9 +2735,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 @@ -2728,12 +2983,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. @@ -2780,7 +3035,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. @@ -2799,7 +3054,7 @@ auction, TLS, or creative data and are for controlled non-production use only. | `auction_html_comment` | Boolean | `false` | Insert an auction diagnostic comment before `` | | `inject_adm_for_testing` | Boolean | `false` | Enable the direct GAM-replace test path and raw `debug_bid` data | | `auction_html_comment_options.include_provider_responses` | Boolean | `true` | Include provider response summaries | -| `auction_html_comment_options.include_mediator_response` | Boolean | `true` | Include mediator response summaries | +| `auction_html_comment_options.include_adserver_response` | Boolean | `true` | Include the ad server's response summary | | `auction_html_comment_options.include_bids` | Boolean | `true` | Include provider bid arrays | | `auction_html_comment_options.metadata_keys` | Array[String] | `error_type`, `http_status`, `message` | Must be a subset of this fixed allowlist | | `auction_html_comment_options.verbosity` | String | `"redacted"` | `redacted`, `upstream`, or `full` | @@ -2953,46 +3208,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 +- `module`, when set, is a module name 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]`, `[ad-server]` and + `[auction.bidders]`, so an unselected table, a name that is not a module name, + 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 module needs when no geo module 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 5edf64c9eb..9d23119982 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -800,7 +800,8 @@ The Trusted Server JavaScript (TSJS) library is automatically injected: ### Integration bundles -The integration registry selects TSJS modules by enabled integration ID. A +The integration registry selects TSJS modules by the integrations the +settings select, each known to the browser by its integration ID. A registered ID with a compiled module is included in the immediate unified bundle unless its builder calls `.with_deferred_js()`; deferred modules are served separately as `/static/tsjs=tsjs-.min.js`. Builders can call @@ -819,7 +820,7 @@ Each integration is built as a separate IIFE at compile time (into Cargo's `OUT_ - 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 cccb04daee..87122b4fac 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" +module = "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: +- `module = "hmac"` selects the built-in HMAC module, 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 module code, and a bare value with no prefix, as a browser may already hold, still resolves) ## 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. Within one request, valid mappings are grouped by normalized EC ID and the last valid UID for each group is applied once; unchanged UIDs are accepted without a write. Group outcomes are reported for every original mapping, and infrastructure failures abort the remaining groups. See the [API Reference](/guide/api-reference) for the complete accounting and failure contract. +Important: request field is `ec_id` (the full value as issued, `hmac~{64hex}.{6alnum}`, with the bare form that has no prefix 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. Within one request, valid mappings are grouped by normalized EC ID and the last valid UID for each group is applied once; unchanged UIDs are accepted without a write. Group outcomes are reported for every original mapping, and infrastructure failures abort the remaining groups. See the [API Reference](/guide/api-reference) for the complete accounting and failure contract. ```bash BATCH_UID="${PARTNER_UID}-batch" diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 113066cdaf..8204b793a4 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 module 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 are not set for the current request (for example a location lookup that failed, or a baseline that requires a signal no module has answered), 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 module 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 module 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 module returns. A region rule takes precedence over its country, and when no country is identified, or neither the region nor the country has a rule, the baseline on the top node of the rules tree applies. +- **Consent and privacy signals.** TCF, GPP, a US Privacy string, GPC and the Model Terms for Marketing preference (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`, `__mtm_pref`), each read by its own [permission signal module](/guide/permission-signals) and mapped onto permissions as a **grant or a revoke** on top of that baseline, in the configured order. There is no separate consent KV fallback. + +Every permission is resolved this way, and the Edge Cookie is created only when each permission its module requires is set. A location lookup that fails resolves at the requires-signal floor, 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 live in the policy file rather than in core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. Writing browser-supplied Prebid EIDs into the identity graph, and server-side pull sync, additionally need the storage and personalised-ad permissions both set, the pair that also gates EIDs in the bid request. With storage alone the Edge Cookie stays first-party and partner sharing is withheld, so the EC ID is not sent to pull-sync partners. See [Prebid EID Flow](#prebid-eid-flow). + +```mermaid +flowchart TD + Start[Resolve country and region] --> Baseline[Country or region rule,
else the top of the rules tree] + Baseline --> Signals[Apply consent/privacy signals
as a grant or revoke] + Signals --> Check{Module'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, withdrawal tombstones, and completion markers that prevent stale point-read misses from rewriting completed tombstones. A marker key records the original tombstone's validity bound and is ignored at or after that time, even if its KV row has not expired yet. If the clock is unusable, the marker is ignored and withdrawal falls back to a strong root-existence check; only a confirmed existing root can be written. This prevents a stale marker from suppressing withdrawal when an expired EC key is created again. With a healthy store, repeated withdrawal of an already tombstoned EC ID leaves the row unchanged instead of refreshing its 24-hour TTL. Permission resolution for each request is based on the live request signals listed above. + +## Module Types: Server-Side and Client-Side -## Consent Model +The Edge Cookie identifier comes from a configurable module, selected by `[ec] module`. A module 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 module, or the built-in `host_signals` module that derives an identifier from the host's TLS JA4 and HTTP/2 signals on a host that supplies them). The module 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 module). The module cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the module's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The module 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{"Module 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 module JS"] + CPage --> CBox[["Module 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. Writing browser-supplied Prebid EIDs into the identity graph additionally requires TCF Purpose 4 (personalized ads); see [Prebid EID Flow](#prebid-eid-flow). Without Purpose 4, server-side pull sync is also skipped, so the EC ID is not sent to pull-sync partners. -- **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 module 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 module 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 module's responsibility**. A client-side module must verify the payload (for example a signature) before creating an identifier, or a client could forge an Edge Cookie. The endpoint itself is module-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the module, 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, withdrawal tombstones, and completion markers that prevent stale point-read misses from rewriting completed tombstones. A marker key records the original tombstone's validity bound and is ignored at or after that time, even if its KV row has not expired yet. If the clock is unusable, the marker is ignored and withdrawal falls back to a strong root-existence check; only a confirmed existing root can be written. This prevents a stale marker from suppressing withdrawal when an expired EC key is created again. With a healthy store, repeated withdrawal of an already tombstoned EC ID leaves the row unchanged instead of refreshing its 24-hour TTL. Consent interpretation for each request remains based on the live request signals listed above. +A built-in `client_fixed` module 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 module 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 @@ -156,7 +180,7 @@ Browser-resolved Prebid EIDs reach the identity graph when Trusted Server finali The `/auction` body carries the full EID set Prebid.js resolved, so `/auction` ingestion is not limited by the size cap TSJS applies to the `ts-eids` cookie. The cookie still bridges Prebid user ID modules with the identity graph on requests that carry no EID body, such as `GET /_ts/page-bids` and page navigations. -These Prebid EID writes follow the same consent rule as bidstream EID forwarding: when a TCF signal is present, Purpose 1 (store/access device) and Purpose 4 (personalized ads) must both be consented, and when GDPR applies without a TCF signal the writes are withheld. A user who consents to Purpose 1 but not Purpose 4 keeps their EC, but no EIDs from the request are persisted. +These Prebid EID writes follow the same permission rule as bidstream EID forwarding: the storage and personalised-ad permissions must both be set. A reader who allows storage but not personalised ads keeps their EC, but no EIDs from the request are persisted. ```mermaid sequenceDiagram @@ -176,7 +200,7 @@ sequenceDiagram Note over B,TS: Next eligible EID-sync request B->>TS: Document navigation, POST /auction,
or admitted GET /_ts/page-bids TS->>TS: Base64 decode → parse OpenRTB-style EIDs
match source domains to partners - TS->>KV: Add missing partner IDs in one conditional write
skip when UIDs already match
(requires TCF Purpose 1 + 4 under GDPR) + TS->>KV: Add missing partner IDs in one conditional write
skip when UIDs already match
(requires the storage and personalised-ad permissions) ``` Current TSJS writers preserve the full OpenRTB-style `{source, uids:[...]}` shape in `ts-eids`. The server remains backward-compatible with earlier flattened `{source, id, atype}` cookies during rollout, but new cookies use the structured `uids[]` form. @@ -225,7 +249,7 @@ sequenceDiagram DSP-->>PS: OpenRTB bid response PS-->>TS: OpenRTB seatbid response TS-->>B: Auction response + x-ts-eids header when available - TS->>KV: Add missing partner UIDs from request eids[]
+ ts-eids/sharedId cookies (TCF Purpose 1 + 4 under GDPR) + TS->>KV: Add missing partner UIDs from request eids[]
+ ts-eids/sharedId cookies (storage and personalised-ad permissions) ``` The relevant OpenRTB structure forwarded to Prebid Server and downstream partners is: @@ -310,7 +334,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` validates every input, groups valid mappings by normalized EC ID, and applies the last valid UID for each group once. Group outcomes still account for every original input; infrastructure failures reject the failing and remaining groups. Mapping timestamps remain required for API compatibility but do not order writes. See the [API Reference](/guide/api-reference) for the complete contract. - 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 c8d48be43d..b441ca79cb 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.module = "hmac"`) --- @@ -94,15 +94,13 @@ credentials or fragment: ```toml # ❌ Wrong -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand.pbs_main] +implementation = "auction.prebid-server" endpoint = "prebid.example.com/openrtb2/auction" # ✅ Correct -[auction.providers.pbs-main] -protocol = "openrtb-2.6" -profile = "prebid-server" +[demand.pbs_main] +implementation = "auction.prebid-server" endpoint = "https://prebid.example.com/openrtb2/auction" ``` @@ -118,13 +116,12 @@ Failed to parse environment variable: TRUSTED_SERVER__PUBLISHER__DOMAIN **Cause:** Environment variable format doesn't match expected type -**Solution:** Override an existing scalar leaf with the expected type. Provider -map keys preserve hyphens, so shell users must invoke the CLI through `env`: +**Solution:** Override an existing scalar leaf with the expected type. Demand +source names are snake_case, so each maps straight onto a path segment: ```bash env 'TRUSTED_SERVER__PUBLISHER__DOMAIN=example.com' \ - 'TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__TIMEOUT_MS=1000' \ - 'TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true' \ + 'TRUSTED_SERVER__DEMAND__PBS_MAIN__TIMEOUT_MS=1000' \ ts config validate ``` @@ -149,10 +146,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` module is selected and its `passphrase` names a secret-store entry in `trusted-server.toml`: ```toml [ec] +module = "hmac" + +[ec.hmac] passphrase = "ec_passphrase" ``` @@ -160,7 +160,8 @@ passphrase = "ec_passphrase" passphrase value: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase +TRUSTED_SERVER__EC__MODULE=hmac +TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase ``` 3. Provision a high-entropy value of at least 32 characters under @@ -176,9 +177,9 @@ TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase Backend not found: prebid-server ``` -**Cause:** Dynamic backend creation for a configured provider endpoint failed. -Provider backends are derived from `[auction.providers.]`; they are not -manually named static Fastly backends. +**Cause:** Dynamic backend creation for a configured demand endpoint failed. +Demand backends are derived from `[demand.]`; they are not manually +named static Fastly backends. **Solution:** @@ -227,14 +228,14 @@ Upstream request timeout after 1000ms **Solution:** -1. Increase the affected server provider timeout: +1. Increase the affected demand source's timeout: ```toml -[auction.providers.pbs-main] +[demand.pbs_main] timeout_ms = 2000 ``` -Browser `[integrations.prebid].timeout_ms` is independent and does not control +Browser `[auction.prebid].timeout_ms` is independent and does not control Prebid Server transport. 2. Verify upstream service is responsive: @@ -289,14 +290,14 @@ Prebid Server returned 400: Invalid OpenRTB request **Solution:** -1. Enable debug mode on the Prebid Server profile: +1. Enable debug mode on the Prebid Server demand source: ```toml -[auction.providers.pbs-main] -profile_config = { debug = true } +[demand.pbs_main] +debug = true ``` -`[integrations.prebid].debug` controls browser Prebid.js only. +`[auction.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 +332,10 @@ Next.js links still pointing to origin domain 2. Update `rewrite_attributes` to match actual keys: ```toml -[integrations.nextjs] -enabled = true +[framework] +module = "nextjs" + +[framework.nextjs] rewrite_attributes = ["href", "link", "url", "src"] # Add keys you find ``` @@ -360,7 +363,10 @@ Failed to fetch Permutive SDK: 404 Not Found 2. Update configuration: ```toml -[integrations.permutive] +[audience] +module = "permutive" + +[audience.permutive] organization_id = "myorg" workspace_id = "workspace-123" ``` @@ -381,15 +387,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:** No section selects the integration's module, or the route is not registered **Solution:** -1. Enable integration: +1. Select the module in the section of its type so it runs: ```toml -[integrations.custom] -enabled = true +[testing] +modules = ["custom"] ``` 2. Verify integration is compiled in (check build logs) @@ -668,18 +674,21 @@ 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 `[auction.prebid]`: ```toml -[integrations.prebid] +[auction] +modules = ["prebid"] + +[auction.prebid] debug = true ``` -For Prebid Server diagnostics, enable debug in that provider's profile: +For Prebid Server diagnostics, enable debug on that demand source: ```toml -[auction.providers.pbs-main] -profile_config = { debug = true } +[demand.pbs_main] +debug = true ``` **Check Fastly logs:** diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index fa64280542..aa1f402d4b 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -336,8 +336,11 @@ Configure the secret-store key name in `trusted-server.toml`: ```toml [ec] -passphrase = "ec_passphrase" +module = "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 556a7abfd2..235825265f 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 d83b0cc863..ec34096767 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -174,18 +174,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 page modules that run, each selected in the section of its type (`[cmp]`, `[tag]`, `[ad-tag]`, `[bot-protection]`, `[identity]`, `[audience]`, `[framework]`, and `[auction]` for Prebid), with their settings under `[
.]` +- the demand sources, in `[demand] modules`, each with its settings under `[demand.]` +- the ad server, if one runs, in `[ad-server] module`, with its settings under `[ad-server.]` +- 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`. +`[auction.prebid]`. Those server values belong to a `[demand.]` table +whose `implementation` is `auction.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 f127d4207c..d10be83bc5 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -7,8 +7,10 @@ Axum, or Spin SDK types. ## Choose the narrowest hook -- `IntegrationProxy` owns explicit method/path endpoints and receives - `Settings`, `RuntimeServices`, and an EdgeZero-neutral request. +- `IntegrationProxy` owns explicit method/path endpoints and receives an + EdgeZero-neutral request and its call into the request's module context, + from which it names the `Settings`, the `RuntimeServices` and whatever + else it needs. - `IntegrationAttributeRewriter` inspects selected HTML attributes. - `IntegrationScriptRewriter` handles one declared selector. - `IntegrationHeadInjector` inserts deterministic head markup. @@ -130,10 +132,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) } @@ -191,12 +195,21 @@ is omitted, so adapter startup must construct the complete service graph. ## Proxy implementation rules -An `IntegrationProxy::handle` implementation receives the complete runtime -service graph. Register or predict backends through `services.backend()`, -send through `services.http_client()`, bound request and response bodies, and -return `Report` with integration context. The registry -strips internal identity headers before dispatch; an integration must opt into -any explicit forwarding behavior. +An `IntegrationProxy::handle` implementation receives the request and its +`ModuleCall`. It hands a function of its own to `call.inject_with`, with the +request as that function's own argument, and names what else it needs as +parameters, such as `&Settings` and `&RuntimeServices`, which is the complete +runtime service graph. Register or predict backends through +`services.backend()`, send through `services.http_client()`, bound request and +response bodies, and return `Report` with the route's +context. The registry strips internal identity headers before dispatch, and a +route must opt into any explicit forwarding behavior. + +A route that names a value whose use needs a permission, such as the Edge +Cookie identifier, declares that permission in `required_permissions` and is +passed the value only on a request that grants it. Nothing gates the route +itself, so a route that must not run without a permission reads the +`&PermissionState` and refuses. Streaming support is an adapter capability. Request `PlatformHttpRequest::with_stream_response()` only when the caller has @@ -212,12 +225,356 @@ DOM insertion dispatcher, remain idempotent, and leave unmatched elements untouched. See [Trusted Server JavaScript](/guide/tsjs) and [GPT's guarded handoff](/guide/integrations/gpt#server-slot-handoff). +## Integrations that ship outside core + +Every integration ships in a crate of its own, which a deployment composes in +at startup, and the hooks described above are what that crate implements. +The vendor owns the code, the release cycle and the integration's own rules, +and core never names the vendor. The crates in this repository sit under +`crates///`, and a vendor's crate can equally live in a +repository of its own. + +`crates/testing/seam-probe` is the worked example. It is a test fixture rather +than something to deploy, and it exercises every part of the seam from a +vendor crate's position. The round-trip tests in +`crates/trusted-server-adapter-axum/tests/seam_probe.rs` drive each part +through the Axum adapter, and the Fastly adapter carries the same round trip. + +### What the crate provides + +The crate hands out an `IntegrationBuilder`, which names the integration and +points at the functions that do the work. + +| Part | Purpose | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Id | Names the integration in its routes under `/integrations//`, in its browser module and in diagnostics | +| Source | The crate or package name, reported when two builders claim one id, so an operator can tell which crates collided | +| Build function | Reads `Settings` and returns the registration. It is called only when a section selects the module | +| Validate function | The integration's own deploy rules. It runs for every builder, selected or not | +| `.with_module_name(".")` | The name a section selects the module by, which is the crate's path under `crates/` with `.` between the parts. `module_name!()` derives it from the crate's folder | +| `.with_request_preparer(...)` | Optional. Runs before routing, once for a request, whether or not the module is selected | +| `.with_response_finalizer(...)` | Optional. Finishes the response the page path returns, from what the module's request hooks left for the request | +| `.with_auction_token()` | Optional. Declares that the module's browser script reads the token an auction publishes with its winning bids, which is made only when a selected module reads one | +| `.with_secret_settings(...)` | Optional. Lists the settings in the module's own table that hold the name of a secret, so each is looked up as the settings load | +| `.with_plan_registration(...)` | Optional. Registers the module from the compiled auction plan, for a module whose page support follows what the plan selects. It runs whether or not a section selects the module, and its hooks go first | +| `.with_plan_validator(...)` | Optional. Checks the module's settings against the compiled auction plan, when a deployment is validated and as the settings load | +| `.with_demand(...)` and `.with_adserver(...)` | Optional. Registers an auction implementation that `[demand]` or `[ad-server]` can name. A crate that supplies nothing else starts from `IntegrationBuilder::implementations(...)` | + +```rust +pub fn module_name() -> &'static str { + trusted_server_core::module_name!() +} + +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::new(EXAMPLE_ID, EXAMPLE_SOURCE, register, validate) + .with_module_name(module_name()) + .with_request_preparer(prepare_request) +} +``` + +A crate at `crates/cmp/example` is the module `cmp.example`, so a deployment +runs it with `[cmp] module = "example"` and gives it settings in +`[cmp.example]`. + +### Who maintains the crate + +Every crate outside core names its maintainers in its manifest, the way +Prebid.js requires a named maintainer of every adapter. + +```toml +[package.metadata.maintainers] +owner = "Example Vendor" +status = "vendor owned" +``` + +`status` is `vendor owned` once the vendor has adopted the crate, +`seeking vendor owner` while the Trusted Server maintainers hold it for a +vendor, and `project owned` for a crate that is the project's own, such as a +test module. A test in `crates/trusted-server-modules` fails for a crate in +this repository that leaves the declaration out. + +### What a registration can declare + +The build function returns an `IntegrationRegistration`, built with the +builder every integration uses. + +| Declaration | What it does | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `.with_proxy(...)` | Routes the paths the proxy declares | +| `.with_head_injector(...)` | Emits markup at the start of `` and, if it chooses, after the script bundle | +| `.with_attribute_rewriter(...)` | Rewrites attribute values in publisher HTML | +| `.with_script_rewriter(...)` | Rewrites inline script contents | +| `.with_html_stream_processor(...)` | Works on the document as it streams | +| `.with_request_filter(...)` | Inspects a request and can turn it back before it reaches the origin | +| `.with_js_module(CarriedJsModule { source, sha256 })` | Carries the integration's own browser script, built outside `trusted-server-js` | +| `.with_deferred_js()` | Serves the script as its own ` + + diff --git a/tools/permissions-inspector/wasm/.gitignore b/tools/permissions-inspector/wasm/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /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 0000000000..1ea2997e59 --- /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?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b" +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?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b" +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 0000000000..2871aad40c --- /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 modules 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 0000000000..5e8f11cb32 --- /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 0000000000..a826d8388d --- /dev/null +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -0,0 +1,182 @@ +//! 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::module_context::ModuleContext; +use trusted_server_core::permission_signal::PermissionSignalModule; +use trusted_server_core::permissions::{Permission, PermissionMaps}; +use trusted_server_core::platform::GeoInfo; + +/// The signal modules the inspector asks, in the order every adapter offers +/// them when `[permission-signal] modules` names none. +fn modules() -> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::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 modules answers from the consent record rather than from + // request evidence, so empty evidence changes none of their answers. A + // module that read a header or a cookie would need real evidence here. + let evidence = OwnedRequestInfo::default(); + // The page stands for no request, so the modules are asked in the empty + // context with the signals entered. + let context = ModuleContext::empty() + .with_consent(&ctx) + .with_evidence(&evidence); + let modules = modules(); + let maps = PermissionMaps::standard(); + let (state, jurisdiction) = match input.geo.as_str() { + "failed" => { + let state = assemble_permissions(&context, GeoStatus::Failed, &modules); + (state, "unknown".to_string()) + } + "none" => { + let state = assemble_permissions(&context, GeoStatus::NoLocation, &modules); + (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(&context, GeoStatus::Located(&info), &modules); + 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(), + // No `malformed_record`. Whether an absent, unreadable or expired + // signal changes the permissions is each module's own decision, + // taken in its answer, so nothing at this level can report one and + // `false` would read as "none was found". + }) + .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 066ad659e3..a34ee861e9 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] module 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 module, selected the same way, +# with `module` where one runs and `modules` where several run: +# +# [] +# module = "" # modules = [...] where several run +# +# [.] # only when that name has settings +# setting = "value" +# +# The types are ec, geo, device, permission-signal, demand, ad-server and +# integration. A settings table its type's selector 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,67 @@ 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 module selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector below, together with that module'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__MODULE 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 module is host-neutral, and a vendor module ships in its +# own crate that the adapter composes in. +# module = "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 bare origin +# (RFC 6454), being http:// or https:// and a host with an optional port, with +# no path, trailing slash, query or fragment, and any other entry is refused +# when the configuration is pushed or loaded. Entries are 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 module table. Uncomment it together with the +# `module = "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 module could therefore be +# selected as `module = "primary"` and configured in the table below +# instead, which is how a deployment gives a module a name that means +# something to the people running it. +# A module from a crate is named by its folder below crates/, and may be +# written in full, as edgecookie., or with that type folder left off. +# [ec.primary] +# implementation = "hmac" +# passphrase = "ec_passphrase" + +# Built-in host-signal module 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 +# `module = "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 +164,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 module to have that module resolve location instead of the host. +# module = "none" + # ============================================================================= -# OPTIONAL — Core features (disabled/omitted by default) +# OPTIONAL - Core features (disabled/omitted by default) # ============================================================================= +# Device-detection module. 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" module adds the host's TLS/H2 signals to the gate. +# Override at deployment with TRUSTED_SERVER__device__module. +# [device] +# module = "builtin" # or "fastly" to add TLS/H2 signal evidence + +[geo] +# Geo / IP intelligence module. No module 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 +# module = "platform". Override with TRUSTED_SERVER__geo__module. +# module = "platform" +# +# The permission baseline for a request the geo module leaves unmatched +# (and, with no geo module, 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 module, 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 module without a geo module must +# acknowledge that by uncommenting the line below, or select a geo module +# instead. +# assume_single_jurisdiction = true + # Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] # X-Robots-Tag = "noindex" @@ -145,17 +242,17 @@ pull_sync_concurrency = 3 # enabled = false # max_age_seconds = 2592000 -# 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? @@ -166,22 +263,85 @@ pull_sync_concurrency = 3 # mode = "restrictive" # "restrictive" | "newest" | "permissive" # freshness_threshold_days = 30 -[integrations.js_asset_proxy] -enabled = false +# Which permission signal modules 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 +# modules here is a list, whereas in [ec], [geo] and [device] module names +# one module. The order is the policy, because the last module with an +# opinion decides. +# +# Listed below is every module the shipped adapters link, which is also +# exactly what runs when modules is left out. Each is a crate under +# crates/permission-signal, outside the core, and is named by its folder +# there. Remove the ones this +# publisher does not want to act on. A module 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] +# modules = [ +# "gpc", # the Sec-GPC request header, Global Privacy Control +# "gpp", # 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 +# ] +# +# Each name is the module's crate folder below crates/permission-signal, and +# may be written in full, as permission-signal.gpp. +# +# A module that gains settings will take them in a [permission-signal.] +# block named for it. None of these four has settings, so such a block is +# refused rather than ignored. +# +# 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 modules +# are configured. That is error handling rather than a module 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 module 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 integrations are modules, each selected in the section of its type, +# which is [cmp] for a consent prompt, [tag] and [ad-tag] for tags, +# [bot-protection], [identity], [audience], [framework], [auction] for the +# modules the auction runs and [proxy] for the script proxy. A section names +# what runs with `module` where one runs or `modules` where several run, a +# module with settings gets its own [
.] table, and a section +# that selects nothing or a table its section does not select refuses +# startup. Nothing is selected by default. The sections and tables are +# further down, commented out. + +# First-party proxying of named third-party scripts. Uncomment the +# `modules` line under [proxy] below to switch it on. +# [proxy.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" +# +# [[proxy.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] +# The modules [proxy] selects. Uncomment to run the first-party script proxy +# configured in [proxy.js_asset_proxy] above. +# modules = ["js_asset_proxy"] # Verify TLS certs when proxying to HTTPS origins (default true; false only for # local self-signed dev). # certificate_check = true @@ -197,7 +357,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" # @@ -256,9 +416,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, [ad-server] 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. @@ -280,47 +442,69 @@ 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" +# The modules [auction] selects, with their tables under OPTIONAL below. Add +# "testing.testlight" for the Testlight test module. +# modules = ["prebid"] + +# Example demand sources. Remove or customize before enabling auctions. The +# table name is this source's identity, and its `implementation` line names +# the implementation by module path, which is how two Prebid Servers run side +# by side under names of their own. The built-in implementations are +# auction-protocol.openrtb, auction.prebid-server and auction.aps. Every +# endpoint must be HTTPS, or HTTP to 127.0.0.1, ::1 or localhost. +[demand] +modules = ["pbs_main"] + +[demand.pbs_main] +implementation = "auction.prebid-server" endpoint = "https://prebid.example.com/openrtb2/auction" -# `prebid-server` defaults to 1000 ms when omitted. +# `auction.prebid-server` defaults to 1000 ms when omitted. routing = "explicit" - -[auction.providers.pbs-main.profile_config] debug = false test_mode = false consent_forwarding = "both" -[auction.providers.pbs-main.notifications] +[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" +module = "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] modules list above when +# uncommenting this. +# [demand.aps_main] +# implementation = "auction.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 [ad-server] selection the +# highest bid wins. The only implementation this repository ships is +# ad-server.mock, for development and testing, written here as mock because +# its type is this section's. +# [ad-server] +# module = "mock" +# +# [ad-server.mock] +# endpoint = "https://adserver.example.com/decide" +# timeout_ms = 1000 +# Map auction context keys to decision-URL query params: +# [ad-server.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 @@ -333,7 +517,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. @@ -347,7 +531,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", @@ -442,20 +626,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. @@ -471,35 +655,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 module is off by default, because no section below is uncommented. To +# switch one on, uncomment its section's selection and its settings table. A +# module with nothing to set needs no table at all. A table its section does +# not select refuses startup, so a table left behind after a module 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 +# [auction.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 client` and uploading: +# Runtime bundle metadata, set after running `ts prebid client` 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 client`, not by the edge runtime. # Values are exact upstream module stems without `.js`. -# [integrations.prebid.bundle.modules] +# [auction.prebid.bundle.modules] # bidder = ["rubiconBidAdapter"] # Omit user_id for the curated default preset; use [] to select none. # user_id = ["sharedIdSystem"] @@ -514,7 +692,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.modules].user_id, or omit that list to take the +# [auction.prebid.bundle.modules].user_id, or omit that list to take the # generator's default preset. `ts prebid client` 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. @@ -524,63 +702,70 @@ 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]] +# [[auction.prebid.managed_user_ids]] # name = "sharedId" # -# [integrations.prebid.managed_user_ids.storage] +# [auction.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 +# [framework] +# module = "nextjs" + +# [framework.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, selected as "testing.testlight" in +# [auction] modules. `endpoint` is required. +# [auction.testing.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` selects "didomi" in [cmp] +# when it detects Didomi on the audited page; the table below stays yours to +# uncomment and fill. +# [cmp] +# module = "didomi" + +# [cmp.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. Selected with [cmp] module = "sourcepoint". +# [cmp.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 [cmp] module = "osano" +# 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. +# [audience] +# module = "permutive" + +# [audience.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. +# [identity] +# module = "lockr" + +# [identity.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" @@ -588,9 +773,12 @@ 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` selects "datadome" in [bot-protection] when it detects DataDome; +# the table below stays yours to uncomment and fill. +# [bot-protection] +# module = "datadome" + +# [bot-protection.datadome] # sdk_origin = "https://sdk.example.com" # api_origin = "https://api.example.com" # cache_ttl_seconds = 3600 @@ -612,45 +800,43 @@ 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] +# [bot-protection.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` selects "google" in +# [ad-tag] when it detects GPT; the table below stays yours. GAM attribution +# stays off until `gam_attribution_enabled` is set true. The environment +# overlay can only replace a leaf the document already has, and it names a +# section as written, so the variable for this leaf is +# TRUSTED_SERVER__AD-TAG__GOOGLE__GAM_ATTRIBUTION_ENABLED, which a shell's +# `export` cannot set. +# [ad-tag] +# modules = ["google"] + +# [ad-tag.google] +# 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 adding +# "google.diagnostics" to [ad-tag] modules is the whole configuration. + +# Google Tag Manager first-party proxy. `ts audit` selects "google-tag-manager" +# in [tag] and writes this table with the container_id it finds when it +# detects GTM. +# `container_id` is required. +# [tag] +# modules = ["google-tag-manager"] + +# [tag.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. @@ -667,11 +853,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"