diff --git a/.cargo/config.toml b/.cargo/config.toml index ee1ea72246..55a08bd79b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +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. @@ -40,8 +40,8 @@ test-fastly-reuse = "test -p trusted-server-adapter-fastly --target wasm32-wasip # 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 -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 --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" +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/test.yml b/.github/workflows/test.yml index 3f9c95386c..b4a8c411a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -123,6 +123,14 @@ jobs: 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 diff --git a/AGENTS.md b/AGENTS.md index 2c4dc80ff3..cf80a644a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,17 +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); core links none + 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 ``` @@ -384,14 +395,26 @@ 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: - -| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | -| --------------------- | ----------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------- | -| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | HMAC, client_fixed (opt-in, no default) | `crates/edgecookie/` | -| Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | User-Agent only (default) | `crates/device/` | -| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | Disabled, no location (default) | `crates/geo/` | -| Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | None, and with no module every permission stays at its country and region baseline | `crates/permission-signal/` | +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: @@ -430,7 +453,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 95% rename from crates/trusted-server-core/src/integrations/gpt.rs rename to crates/ad-tag/google/src/lib.rs index 3292cc5b3a..3472280ea9 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 - ); - } - } - } - } - - /// Apply compiled profile rules to already centrally routed bidder params. - pub(crate) fn apply_routed(&self, bidder: &str, zone: Option<&str>, params: &mut Json) { - self.apply(BidParamOverrideFacts { bidder, zone }, params); - } -} - -/// Validate and compile server-side Prebid profile override fields. -/// -/// This narrow hook shares the existing override compiler without coupling -/// auction-profile availability to the browser integration's enablement. -pub(crate) fn compile_profile_override_rules( - bid_param_zone_overrides: &std::collections::BTreeMap< - String, - std::collections::BTreeMap>, - >, - bid_param_overrides: &std::collections::BTreeMap>, - bid_param_override_rules: &[BidParamOverrideRule], -) -> Result> { - BidParamOverrideEngine::try_from_profile_config( - bid_param_zone_overrides, - bid_param_overrides, - bid_param_override_rules, - ) -} - -fn merged_rule_indices<'a>( - 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(); - - 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, - }) -} - -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, - ) - } - - 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, - ) - } - - 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()?; - - 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 gated on the - // resolved permission state in `handle_auction` via - // `gate_eids_by_permissions`. - 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 21c4afd750..7610fc248d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -212,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(), @@ -265,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(), @@ -332,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 @@ -741,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 { @@ -810,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), @@ -839,9 +842,9 @@ mod tests { let had_eids = Arc::new(std::sync::Mutex::new(None)); 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 { @@ -917,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 { @@ -994,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) @@ -1051,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( @@ -1103,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), })); @@ -1169,9 +1176,9 @@ 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); @@ -1229,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"); @@ -1293,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); @@ -1423,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); 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 d13d4aa452..412ba7a342 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 { @@ -3673,12 +3595,13 @@ mod tests { } } - struct CanonicalTimeoutBackend { - canonical_ms: u32, + /// Canonicalizes every transport timeout to `CANONICAL_MS`, fixed by the + /// type as a platform's rule is, and records each call's arguments. + struct CanonicalTimeoutBackend { calls: Arc>>, } - impl PlatformBackend for CanonicalTimeoutBackend { + impl PlatformBackend for CanonicalTimeoutBackend { fn naming_policy(&self) -> crate::platform::BackendNamingPolicy { crate::platform::BackendNamingPolicy::Axum } @@ -3699,7 +3622,7 @@ mod tests { .lock() .expect("should lock canonicalization calls") .push((remaining_ms, configured_ms)); - self.canonical_ms + CANONICAL_MS } } @@ -3719,24 +3642,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 +3674,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 +3685,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 +3710,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 +3725,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 +3734,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 +3746,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 +3761,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 +3772,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 +3791,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 +3803,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 +3812,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 +3836,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 +3857,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 +3893,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 +3919,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 +3951,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 +3998,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 +4010,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 +4030,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 +4043,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 +4053,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 +4084,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 +4116,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 +4151,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 +4164,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 +4195,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 +4334,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 +4359,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 +4390,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 +4604,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 +4618,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 +4651,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 +4686,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 +4724,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 })); } } @@ -4868,8 +4791,7 @@ mod tests { stub.push_response(200, b"{}".to_vec()); let calls = Arc::new(Mutex::new(Vec::new())); let services = build_services_with_backend_and_http_client( - Arc::new(CanonicalTimeoutBackend { - canonical_ms: 750, + Arc::new(CanonicalTimeoutBackend::<750> { calls: Arc::clone(&calls), }), stub, @@ -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() }); @@ -4912,17 +4834,14 @@ mod tests { futures::executor::block_on(async { let calls = Arc::new(Mutex::new(Vec::new())); let services = build_services_with_backend_and_http_client( - Arc::new(CanonicalTimeoutBackend { - canonical_ms: 0, - calls, - }), + Arc::new(CanonicalTimeoutBackend::<0> { calls }), Arc::new(StubHttpClient::new()), ); let predicted = Arc::new(Mutex::new(Vec::new())); 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,25 +4867,22 @@ 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()); stub.push_response(200, b"{}".to_vec()); let calls = Arc::new(Mutex::new(Vec::new())); let services = build_services_with_backend_and_http_client( - Arc::new(CanonicalTimeoutBackend { - canonical_ms: 500, - calls, - }), + Arc::new(CanonicalTimeoutBackend::<500> { calls }), stub, ); let predicted = Arc::new(Mutex::new(Vec::new())); 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 +4891,8 @@ mod tests { backend: "bidder-backend", })); orchestrator.register_provider(Arc::new(recording_provider( - "mediator", - "mediator-backend", + "adserver", + "adserver-backend", 2000, &predicted, &requested, @@ -4988,7 +4904,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]); @@ -5003,20 +4919,17 @@ mod tests { stub.push_response(200, b"{}".to_vec()); let calls = Arc::new(Mutex::new(Vec::new())); let services = build_services_with_backend_and_http_client( - Arc::new(CanonicalTimeoutBackend { - canonical_ms: 500, - calls, - }), + Arc::new(CanonicalTimeoutBackend::<500> { calls }), stub, ); 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 +4941,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,43 +4970,48 @@ 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( - Arc::new(CanonicalTimeoutBackend { - canonical_ms: 0, + Arc::new(CanonicalTimeoutBackend::<0> { calls: Arc::clone(&calls), }), Arc::new(StubHttpClient::new()), ); let launches = Arc::new(AtomicUsize::new(0)); let budgets = Arc::new(Mutex::new(Vec::new())); - let plan = AuctionPlan::compile(AuctionPlanConfig { - timeout_ms: 49, - providers: BTreeMap::new(), - bidders: BTreeMap::new(), - mediator: Some("adserver_mock".to_string()), - request_signing: None, - }) - .expect("should compile mediator-only plan") - .with_enabled(true); + let 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 +5037,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 +5053,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 +5077,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 +5091,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 +5120,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 +5146,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 +5193,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 +5225,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 +5275,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 +5322,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 +5359,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 +5380,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 +5460,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 +5526,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 +5591,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 +5643,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 +5661,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 +5679,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 +5699,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 +5714,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 +5742,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 +5763,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 +5786,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 +5807,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 +5828,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 +5838,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 +5859,7 @@ mod tests { } ]}] })) - .expect("should serialize PBS response"), + .expect("should serialize the upstream response"), ); } let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); @@ -5948,24 +5867,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 +5913,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 +5921,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 +5957,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 +5974,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 +5987,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 +5996,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 +6009,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 +6028,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 +6155,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 +6246,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 +6271,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 +6296,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 +6321,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 +6343,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 +6368,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 +6381,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 +6409,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 +6442,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 +6519,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 +6577,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 +6594,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 +6627,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 +6671,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 +6679,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 +6690,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 +6711,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 +6723,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 +6871,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 +6935,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 +7079,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 +7119,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 +7136,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 +7186,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..0bb93bb084 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")] @@ -79,6 +108,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 +121,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 +193,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 +287,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 9587672e6f..a80051eab9 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -16,46 +16,11 @@ use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKin 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, Ec, IntegrationConfig, MODULE_IMPLEMENTATION_KEY, 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. /// @@ -119,11 +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(); remove_labeled_module_secret_errors(&mut errors, &self.settings.ec); - if let Err(report) = validate_settings_for_deploy(&self.settings) { + 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"), @@ -243,15 +231,9 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { let optional_object = |name: &'static str| SecretPathSegment::OptionalField(Cow::Borrowed(name)); - vec![ + let mut fields = vec![ field(vec![object("publisher"), object("proxy_secret")], false), field(vec![object("ec"), object("passphrase")], true), - // The `[ec.hmac]` and `[ec.host_signals]` blocks, the modules - // built into core configured under their own names, are the - // module blocks with fixed paths. The same modules under a - // label of the operator's choosing are reached by - // `ConfiguredSecretFields`, which reads the names out of the - // configuration. field( vec![object("ec"), optional_object("hmac"), object("passphrase")], true, @@ -301,23 +283,6 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { vec![optional_object("tinybird"), object("auction_token_secret")], true, ), - field( - vec![ - optional_object("integrations"), - optional_object("datadome"), - object("server_side_key_secret_name"), - ], - true, - ), - field( - vec![ - optional_object("integrations"), - optional_object("datadome"), - optional_object("protection_test_bypass"), - object("credential_secret_name"), - ], - true, - ), field( vec![ optional_object("proxy"), @@ -348,11 +313,17 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { ], true, ), - ] + ]; + // The settings a module declares in its own table, for core's + // modules and the ones a tool registered for deploy validation. + let builders = crate::integrations::all_builders(deploy_integrations()).collect::>(); + fields.extend(crate::module_secrets::secret_fields(&builders)); + fields } } -/// Runs Trusted Server push-time validation for app config. +/// Runs Trusted Server push-time validation for app config with the built-in +/// integrations only. /// /// Secret fields contain secret-store key names at this stage, so this function /// deliberately excludes checks that require resolved values. The `EdgeZero` CLI @@ -364,22 +335,51 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { /// Returns [`TrustedServerError`] when non-secret configuration or a secret key /// reference is invalid. pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report> { - validate_secret_key_references(settings)?; + validate_settings_for_deploy_with(settings, &[]) +} + +/// Runs push-time validation with the built-in integrations followed by the +/// externally supplied builders an adapter registers. Every builder validates, +/// enabled or not, so a typo in a disabled block is still caught. +/// +/// As in [`validate_settings_for_deploy`], secret fields still hold key names +/// here, so no check reads a resolved secret value. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when non-secret configuration or a secret key +/// reference is invalid, or when a builder rejects its own configuration. +pub fn validate_settings_for_deploy_with( + settings: &Settings, + extra_integrations: &[IntegrationBuilder], +) -> Result<(), Report> { + // The selection is checked first, so a block nothing runs is reported as + // that rather than as whatever its unread settings fail next. + settings.validate_module_sections()?; + validate_secret_key_references(settings, extra_integrations)?; validate_non_secret_deploy_placeholders(settings)?; - validate_js_asset_proxy_config(settings)?; let mut structural_settings = settings.clone(); structural_settings.prepare_runtime()?; structural_settings.validate_admin_coverage()?; - let plan = crate::auction::compile_auction_plan(settings)?; - validate_enabled_integrations(settings, &plan, false)?; + // The plan is compiled with the builders the blocks are validated + // against, so a `[demand]` or `[ad-server]` name one of them supplies + // compiles here. + let plan = crate::auction::compile_auction_plan_with(settings, extra_integrations)?; + validate_integration_blocks(settings, &plan, extra_integrations)?; PartnerRegistry::validate_config_for_deploy(&settings.ec.partners)?; settings.ec.validate_resolve_allowed_origins()?; Ok(()) } -/// Runs Trusted Server runtime validation after secret references are resolved. +/// Runs Trusted Server runtime validation after secret references are +/// resolved, against the built-in integrations only. +/// +/// A deployment whose `[demand]` or `[ad-server]` names an implementation a +/// builder of its own supplies calls [`validate_settings_for_runtime_with`] +/// with that builder, because this function cannot see it and refuses the +/// name as one no builder registers. /// /// # Errors /// @@ -387,92 +387,58 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report Result<(), Report> { + validate_settings_for_runtime_with(settings, &[]) +} + +/// Runs runtime validation with the built-in integrations followed by the +/// builders a deployment supplies. +/// +/// This runs while the settings load, before any application state is built, +/// so a deployment that composes builders supplies them here as well as to +/// the state build. Each builder also validates its own table. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when resolved secrets or runtime-only +/// configuration checks are invalid, or when a builder rejects its own +/// configuration. +pub fn validate_settings_for_runtime_with( + settings: &Settings, + extra_integrations: &[IntegrationBuilder], ) -> Result<(), Report> { settings.reject_placeholder_secrets()?; - validate_js_asset_proxy_config(settings)?; settings.validate_admin_handler_passwords()?; - let plan = crate::auction::compile_auction_plan(settings)?; - validate_enabled_integrations(settings, &plan, true)?; + let plan = crate::auction::compile_auction_plan_with(settings, extra_integrations)?; + validate_integration_blocks(settings, &plan, extra_integrations)?; PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; Ok(()) } -fn validate_js_asset_proxy_config(settings: &Settings) -> Result<(), Report> { - let Some(raw_config) = settings.integrations.get(JS_ASSET_PROXY_INTEGRATION_ID) else { - return Ok(()); - }; - - let config: JsAssetProxyConfig = serde_json::from_value(raw_config.clone()).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!( - "integration startup failed for `{JS_ASSET_PROXY_INTEGRATION_ID}`: configuration could not be parsed: {error}" - ), - }) - })?; - config.validate().map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!( - "integration startup failed for `{JS_ASSET_PROXY_INTEGRATION_ID}`: {error}" - ), - }) - }) -} - -fn validate_enabled_integrations( +/// Validates every integration block, the built-in ones first and then +/// `extra_integrations`. +/// +/// Each builder validates its own block, and one with a rule that depends on +/// what the auction plan selects then checks its block against the plan. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when any integration block fails its +/// validation. +fn validate_integration_blocks( settings: &Settings, plan: &crate::auction::AuctionPlan, - resolved_secrets: bool, + extra_integrations: &[IntegrationBuilder], ) -> Result<(), Report> { - validate_prebid(settings, plan)?; - validate_integration::(settings, "aps")?; - validate_integration::(settings, "adserver_mock")?; - validate_integration::(settings, "testlight")?; - validate_integration::(settings, "nextjs")?; - validate_integration::(settings, "permutive")?; - validate_integration::(settings, "lockr")?; - validate_integration::(settings, "didomi")?; - validate_integration::(settings, "sourcepoint")?; - validate_integration::(settings, "osano")?; - validate_integration::(settings, "google_tag_manager")?; - if let Some(config) = settings.integration_config::("datadome")? { - if resolved_secrets { - crate::integrations::datadome::DataDomeIntegration::validate_config_for_startup( - config, - )?; - } else { - crate::integrations::datadome::DataDomeIntegration::validate_config_for_deploy(config)?; + for builder in crate::integrations::all_builders(extra_integrations) { + builder.validate(settings)?; + if let Some(validate) = builder.plan_validator() { + validate(settings, plan)?; } } - validate_integration::(settings, "gpt")?; - validate_integration::(settings, "gpt_diagnostics")?; - Ok(()) } -fn validate_prebid( - settings: &Settings, - plan: &crate::auction::AuctionPlan, -) -> Result<(), Report> { - let Some(config) = settings.integration_config::("prebid")? - else { - return Ok(()); - }; - prebid::validate_browser_config_for_startup(&config, &settings.proxy.allowed_domains)?; - prebid::validate_browser_bidder_ownership(&config, plan) -} - -fn validate_integration( - settings: &Settings, - integration_id: &str, -) -> Result> -where - T: IntegrationConfig, -{ - settings - .integration_config::(integration_id) - .map(|config| config.is_some()) -} - fn validate_non_secret_deploy_placeholders( settings: &Settings, ) -> Result<(), Report> { @@ -505,7 +471,10 @@ fn validate_non_secret_deploy_placeholders( })) } -fn validate_secret_key_references(settings: &Settings) -> Result<(), Report> { +fn validate_secret_key_references( + settings: &Settings, + extra_integrations: &[IntegrationBuilder], +) -> Result<(), Report> { validate_secret_key_reference( "publisher.proxy_secret", settings.publisher.proxy_secret.expose(), @@ -561,35 +530,24 @@ fn validate_secret_key_references(settings: &Settings) -> Result<(), Report("datadome")? { - if datadome.enable_protection { - let key = datadome - .server_side_key_secret_name - .as_ref() - .ok_or_else(|| { - missing_secret_key_reference( - "integrations.datadome.server_side_key_secret_name", - ) - })?; - validate_secret_key_reference( - "integrations.datadome.server_side_key_secret_name", - key.expose(), - )?; - } - if let Some(bypass) = datadome - .protection_test_bypass - .as_ref() - .filter(|bypass| bypass.enabled) - { - let credential = bypass.credential_secret_name.as_ref().ok_or_else(|| { - missing_secret_key_reference( - "integrations.datadome.protection_test_bypass.credential_secret_name", - ) - })?; - validate_secret_key_reference( - "integrations.datadome.protection_test_bypass.credential_secret_name", - credential.expose(), - )?; + // Each setting a selected module declares as naming a secret, where the + // module's table puts it to use. + for builder in crate::integrations::all_builders(extra_integrations) { + let secrets = builder.secret_settings(); + let Some((section, written)) = builder + .module_name() + .filter(|_| !secrets.is_empty()) + .and_then(|name| settings.module_selection(name)) + else { + continue; + }; + let table = settings.section_table(section, written); + for secret in secrets.iter().filter(|secret| (secret.in_use)(&table)) { + let path = format!("{section}.{written}.{}", secret.path.join(".")); + let reference = crate::module_secrets::value_at(&table, secret.path) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| missing_secret_key_reference(&path))?; + validate_secret_key_reference(&path, reference)?; } } @@ -643,18 +601,37 @@ fn report_to_validation_error( #[cfg(test)] mod tests { - use std::collections::HashSet; + use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; - use crate::auction_config_types::{NotificationConfig, ProviderConfig, RoutingMode}; + use crate::integrations::IntegrationRegistration; + use crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID; use crate::redacted::Redacted; use crate::settings::{ProxyAssetRoute, S3SigV4AuthConfig, TrustedClientIpConfig}; + use crate::test_support::template::{template_with_resolved_required_secrets, uncomment_block}; use crate::test_support::tests::{ crate_test_settings_str, crate_test_settings_str_with_ec_section, select_hmac_module, }; use edgezero_core::app_config::AppConfigMeta; use edgezero_core::blob_envelope::BlobEnvelope; + /// Message an external builder rejects with, so the test can prove the + /// rejection reached the caller intact. + const EXTERNAL_REJECTION_MESSAGE: &str = "seam probe refuses to deploy"; + + /// Stands in for a vendor integration builder that never registers. + fn build_nothing( + _settings: &Settings, + ) -> Result, Report> { + Ok(None) + } + + fn reject_deploy(_settings: &Settings) -> Result> { + Err(Report::new(TrustedServerError::Configuration { + message: EXTERNAL_REJECTION_MESSAGE.to_string(), + })) + } + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] #[allow(dead_code)] @@ -721,20 +698,21 @@ formats = [{ width = 300, height = 250 }] source["auction"]["allowed_context_keys"] = serde_json::json!([ "zeta", "alpha", "gamma", "beta", "epsilon", "delta", "alpha" ]); - source["auction"]["providers"] = serde_json::json!({ + source["demand"] = serde_json::json!({ + "modules": ["secondary", "primary"], "secondary": { - "protocol": "openrtb-2.6", "endpoint": "https://secondary.example.com/auction", - "notifications": {"suppress_seats": ["seat-b", "seat-a"]}, - "profile_config": {"second": 2, "first": 1} + "implementation": "auction.plain-fixture", "endpoint": "https://secondary.example.com/auction", + "routing": "all_eligible", + "notifications": {"suppress_seats": ["seat-b", "seat-a"]} }, "primary": { - "protocol": "openrtb-2.6", "endpoint": "https://primary.example.com/auction", - "notifications": {"suppress_seats": ["seat-b", "seat-a"]}, - "profile_config": {"second": 2, "first": 1} + "implementation": "auction.plain-fixture", "endpoint": "https://primary.example.com/auction", + "routing": "all_eligible", + "notifications": {"suppress_seats": ["seat-b", "seat-a"]} } }); source["auction"]["bidders"] = serde_json::json!({ - "bidder-b": {"provider": "secondary"}, "bidder-a": {"provider": "primary"} + "bidder-b": {"module": "secondary"}, "bidder-a": {"module": "primary"} }); source["creative_opportunities"] = serde_json::json!({ "gam_network_id": "99999", @@ -774,121 +752,6 @@ formats = [{ width = 300, height = 250 }] } } - fn insert_aps_provider(settings: &mut Settings, account_id: &str) { - settings.auction.providers.insert( - "aps-main".parse().expect("should parse APS provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "aps".to_string(), - endpoint: "https://aps.example.com/e/pb/bid".to_string(), - timeout_ms: None, - routing: RoutingMode::AllEligible, - notifications: NotificationConfig::default(), - profile_config: serde_json::json!({ "account_id": account_id }), - }, - ); - } - - /// Source-controlled operator-facing config template. - const EXAMPLE_TEMPLATE: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../trusted-server.example.toml" - )); - - /// Returns the template with required secret-store key references replaced - /// by resolved test values, so direct [`Settings`] parsing can exercise the - /// optional blocks this module uncomments. - 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 (a blank line ends the block). - 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") - } - - /// Every documented block should be push-ready: uncommenting it and setting - /// the shown values must parse and pass field validation. Blocks that ship - /// a deliberately-invalid non-secret placeholder (GTM `container_id` and - /// `request_signing` store ids) are excluded. - #[test] - fn documented_integration_blocks_validate_when_uncommented() { - let base = template_with_resolved_required_secrets(); - - for (header, id) in [ - ("[integrations.permutive]", "permutive"), - ("[integrations.lockr]", "lockr"), - ("[integrations.sourcepoint]", "sourcepoint"), - ] { - let toml = uncomment_block(&base, header); - let settings = Settings::from_toml(&toml) - .unwrap_or_else(|err| panic!("uncommented {header} should parse: {err:?}")); - - match id { - "permutive" => assert!( - settings - .integration_config::(id) - .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) - .is_some(), - "{header} should resolve to an enabled, valid config" - ), - "lockr" => assert!( - settings - .integration_config::(id) - .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) - .is_some(), - "{header} should resolve to an enabled, valid config" - ), - "sourcepoint" => assert!( - settings - .integration_config::(id) - .unwrap_or_else(|err| panic!("{header} should validate: {err:?}")) - .is_some(), - "{header} should resolve to an enabled, valid config" - ), - other => panic!("unhandled integration id {other}"), - } - } - } - - /// The `[tinybird]` block is top-level and validated at parse time, so - /// uncommenting it with the documented `api_host` must parse cleanly. #[test] fn documented_tinybird_block_validates_when_uncommented() { let toml = uncomment_block(&template_with_resolved_required_secrets(), "[tinybird]"); @@ -1174,15 +1037,6 @@ formats = [{ width = 300, height = 250 }] ("handlers[*].password".to_owned(), false), ("trusted_client_ip.shared_secret".to_owned(), false), ("tinybird.auction_token_secret".to_owned(), true), - ( - "integrations.datadome.server_side_key_secret_name".to_owned(), - true, - ), - ( - "integrations.datadome.protection_test_bypass.credential_secret_name" - .to_owned(), - true, - ), ("proxy.asset_routes[*].auth.access_key_id".to_owned(), true), ( "proxy.asset_routes[*].auth.secret_access_key".to_owned(), @@ -1235,20 +1089,6 @@ formats = [{ width = 300, height = 250 }] fn legacy_static_secret_store_selectors_are_accepted_but_not_serialized() { let mut settings = valid_settings(); settings.tinybird.secret_store = Some("legacy-tinybird-store".to_string()); - settings - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "server_side_key_secret_store": "legacy-datadome-store", - "protection_test_bypass": { - "enabled": false, - "credential_secret_store": "legacy-bypass-store", - }, - }), - ) - .expect("should insert legacy DataDome selectors"); let mut route = ProxyAssetRoute::new( "/assets/", "https://examplebucket.s3.us-east-1.amazonaws.com", @@ -1268,12 +1108,7 @@ formats = [{ width = 300, height = 250 }] .expect("should normalize settings without key collisions"); let serialized = serde_json::to_string(&settings).expect("should serialize settings"); - for legacy_store in [ - "legacy-tinybird-store", - "legacy-datadome-store", - "legacy-bypass-store", - "legacy-s3-store", - ] { + for legacy_store in ["legacy-tinybird-store", "legacy-s3-store"] { assert!( !serialized.contains(legacy_store), "serialized config should omit deprecated selector {legacy_store}" @@ -1287,21 +1122,20 @@ formats = [{ width = 300, height = 250 }] settings.tinybird.auction_token_secret = Some(Redacted::new("resolved-tinybird-secret".to_string())); settings - .integrations - .insert_config( - "datadome", + .insert_module_config( + "testing", + "testing.example", &serde_json::json!({ - "enabled": true, - "server_side_key_secret_name": "resolved-datadome-secret", + "key_name": "resolved-module-secret", }), ) - .expect("should insert resolved DataDome config"); + .expect("should insert a module's table holding a resolved secret"); let debug = format!("{settings:?}"); assert!(!debug.contains("resolved-tinybird-secret")); - assert!(!debug.contains("resolved-datadome-secret")); - assert!(debug.contains("datadome")); + assert!(!debug.contains("resolved-module-secret")); + assert!(debug.contains("example")); } #[test] @@ -1325,8 +1159,9 @@ formats = [{ width = 300, height = 250 }] #[test] fn wrapper_rejects_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 = toml::from_str::(&toml) @@ -1337,8 +1172,8 @@ formats = [{ width = 300, height = 250 }] "should identify the removed field: {rendered}" ); assert!( - rendered.contains("CHANGELOG.md"), - "should direct operators to migration guidance: {rendered}" + rendered.contains("[demand] modules"), + "should name where the setting moved to: {rendered}" ); } @@ -1573,31 +1408,6 @@ password = "production-admin-password-32-bytes" ); } - #[test] - fn deploy_validation_rejects_blank_aps_account_id() { - for (label, account_id) in [("empty", ""), ("whitespace-only", " ")] { - let mut settings = valid_settings(); - insert_aps_provider(&mut settings, account_id); - - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject blank APS account_id"); - - assert!( - format!("{err:?}").contains("account_id"), - "should mention the APS profile account_id for {label}: {err:?}" - ); - } - } - - #[test] - fn deploy_validation_normalizes_padded_aps_account_id() { - let mut settings = valid_settings(); - insert_aps_provider(&mut settings, " example-account "); - - validate_settings_for_deploy(&settings) - .expect("should accept a padded APS profile account_id after trimming it"); - } - #[test] fn deploy_validation_rejects_padded_request_signing_store_ids() { let mut settings = valid_settings(); @@ -1618,258 +1428,405 @@ password = "production-admin-password-32-bytes" ); } - /// Integrations that default to disabled do not validate inactive fields. + /// A table written for a module its section does not select is refused + /// when the settings load, so `ts config validate` reports it before the + /// configuration reaches a deployment. #[test] - fn deploy_validation_skips_field_validation_for_integrations_with_omitted_enabled() { - let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "adserver_mock", - &serde_json::json!({ "endpoint": "not-a-valid-url" }), - ) - .expect("should insert adserver_mock config"); + fn a_table_for_a_module_its_section_does_not_select_is_refused() { + let mut value = serde_json::to_value(valid_settings()).expect("should serialize settings"); + value + .as_object_mut() + .expect("settings should serialize as an object") + .insert( + "cmp".to_owned(), + serde_json::json!({ "modules": ["osano"], "didomi": { "api_key": "k" } }), + ); - validate_settings_for_deploy(&settings).expect( - "should skip field validation for integrations that resolve to disabled via default", + let error = + Settings::from_json_value(value).expect_err("should reject a table nothing selects"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("[cmp.didomi] is configured") && rendered.contains("does not select"), + "should name the table and the selection it is missing from: {rendered}" ); } + /// An id no builder in this deployment supplies is refused where the + /// registry is built, not here, because a vendor crate the CLI never links + /// may supply it. #[test] - fn deploy_validation_rejects_retired_aps_fields_when_explicitly_disabled() { + fn deploy_validation_accepts_an_id_it_does_not_know() { let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "aps", - &serde_json::json!({ - "enabled": false, - "endpoint": "https://aps.example.com/e/pb/bid" - }), - ) - .expect("should insert disabled APS config with a retired field"); + settings.select_module("testing", "testing.a_vendors_own_integration"); - let error = validate_settings_for_deploy(&settings) - .expect_err("should reject retired APS server fields when explicitly disabled"); - let rendered = format!("{error:?}"); - assert!( - rendered.contains("Integration 'aps' configuration could not be parsed"), - "should identify the APS configuration: {rendered}" - ); - assert!( - rendered.contains("endpoint"), - "should identify the retired APS field: {rendered}" - ); + validate_settings_for_deploy(&settings) + .expect("deploy validation should leave unknown ids to the registry"); } - #[test] - fn deploy_validation_rejects_external_prebid_bundle_without_proxy_allowed_domains() { - let mut settings = valid_settings(); - settings.proxy.allowed_domains.clear(); + /// Counts calls to [`record_validate_call`]. A builder holds plain fn + /// pointers and cannot capture, so the recording has to go through a + /// static. + static RECORDED_VALIDATE_CALLS: AtomicUsize = AtomicUsize::new(0); - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject external Prebid bundle without proxy allowlist"); + fn record_validate_call(_settings: &Settings) -> Result> { + RECORDED_VALIDATE_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(false) + } - assert!( - err.to_string().contains("proxy.allowed_domains"), - "error should mention proxy.allowed_domains: {err:?}" - ); + /// 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: &serde_json::Map, + ) -> Result< + std::sync::Arc, + Report, + > { + Ok(std::sync::Arc::new( + crate::auction::test_support::adserver_fixture::FixtureAdServer::new(name, 500), + )) } - #[test] - fn validate_rejects_invalid_enabled_js_asset_proxy_config() { - let mut settings = valid_settings(); - settings.integrations.insert( - "js_asset_proxy".to_string(), - serde_json::json!({ "enabled": true }), - ); + /// The builder of the crate that supplies [`EXTERNAL_ADSERVER`]. + fn external_adserver_builders() -> [IntegrationBuilder; 1] { + [ + IntegrationBuilder::implementations("example-adserver", "example-crate") + .with_adserver(&EXTERNAL_ADSERVER), + ] + } - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject invalid JS asset proxy config"); - let message = err.to_string(); - assert!( - message.contains("js_asset_proxy"), - "error should mention JS asset proxy validation" - ); - assert!( - message.contains("empty_assets") || message.contains("assets"), - "error should mention the missing assets" + /// Settings whose `[ad-server] module` names the external ad server. + fn settings_naming_the_external_adserver() -> Settings { + let mut settings = valid_settings(); + settings.adserver = crate::provider_table::ProviderChoice::new( + Some("example".to_string()), + std::collections::BTreeMap::from([("example".to_string(), serde_json::Map::new())]), ); + settings } + /// Deploy validation compiles the plan with the builders it validates the + /// blocks against, so it and the running service agree on which ad server + /// names are valid. Both halves are asserted, so the test fails if the + /// built-in path stops refusing the name or the external path stops + /// accepting it. #[test] - fn deploy_validation_requires_external_bundle_url_for_enabled_prebid() { - let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "prebid", - &serde_json::json!({ - "enabled": true, - "bundle": { - "modules": { "bidder": ["exampleBidderBidAdapter"] } - } - }), - ) - .expect("should insert enabled Prebid config"); + fn deploy_validation_accepts_an_external_ad_server_only_when_given_its_builder() { + let settings = settings_naming_the_external_adserver(); let error = validate_settings_for_deploy(&settings) - .expect_err("should require enabled Prebid external bundle URL"); - assert!(error.to_string().contains("external_bundle_url")); + .expect_err("built-ins alone should not know this ad server"); + assert!( + error.to_string().contains("example"), + "should name the ad server: {error:?}" + ); + + validate_settings_for_deploy_with(&settings, &external_adserver_builders()) + .expect("the external builder's ad server should pass deploy validation"); } + /// Runtime validation runs while the settings load, before any application + /// state exists, so a deployment that composes a builder supplies it here + /// too. Both halves are asserted, so the test fails if the built-in path + /// stops refusing the name or the external path stops accepting it. #[test] - fn deploy_validation_rejects_conflicting_prebid_browser_bidder_ownership() { - let mut settings = valid_settings(); - settings.auction.enabled = true; - settings.auction.providers = crate::auction::AuctionConfig::legacy_provider_map(&["pbs"]); - settings.auction.bidders.insert( - "exampleBidder" - .parse() - .expect("should parse server-side bidder"), - crate::auction::BidderRouteConfig { - provider: "pbs".parse().expect("should parse provider"), - }, - ); - let mut prebid = settings - .integration_config::("prebid") - .expect("should parse Prebid config") - .expect("should have enabled Prebid config"); - prebid.client_side_bidders = vec!["exampleBidder".to_string()]; - settings - .integrations - .insert_config("prebid", &prebid) - .expect("should replace Prebid config"); + fn runtime_validation_accepts_an_external_ad_server_only_when_given_its_builder() { + let settings = settings_naming_the_external_adserver(); - let error = validate_settings_for_deploy(&settings) - .expect_err("should reject conflicting browser bidder ownership"); - assert!(error.to_string().contains("exampleBidder")); + let error = validate_settings_for_runtime(&settings) + .expect_err("built-ins alone should not know this ad server"); assert!( - error - .to_string() - .contains("both client-side and server-side") + error.to_string().contains("example"), + "should name the ad server: {error:?}" ); + + validate_settings_for_runtime_with(&settings, &external_adserver_builders()) + .expect("the external builder's ad server should pass runtime validation"); } + /// Every builder handed to deploy validation has its `validate` run, and + /// reporting disabled does not excuse a builder from validating. #[test] - fn deploy_validation_accepts_dormant_prebid_browser_bidder_overlap() { - let mut settings = valid_settings(); - settings.auction.enabled = false; - settings.auction.providers = crate::auction::AuctionConfig::legacy_provider_map(&["pbs"]); - settings.auction.bidders.insert( - "exampleBidder" - .parse() - .expect("should parse server-side bidder"), - crate::auction::BidderRouteConfig { - provider: "pbs".parse().expect("should parse provider"), - }, + fn deploy_validation_runs_every_builder_it_is_given() { + RECORDED_VALIDATE_CALLS.store(0, Ordering::SeqCst); + let extra_integrations = [IntegrationBuilder::new( + "seam-probe-integration", + "seam-probe-crate", + build_nothing, + record_validate_call, + ) + .with_module_name("testing.seam-probe-integration")]; + validate_settings_for_deploy_with(&valid_settings(), &extra_integrations) + .expect("should accept settings whose external builder reports disabled"); + + assert_eq!( + RECORDED_VALIDATE_CALLS.load(Ordering::SeqCst), + 1, + "the external builder should validate even though it reports disabled" ); - let mut prebid = settings - .integration_config::("prebid") - .expect("should parse Prebid config") - .expect("should have enabled Prebid config"); - prebid.client_side_bidders = vec!["exampleBidder".to_string()]; - settings - .integrations - .insert_config("prebid", &prebid) - .expect("should replace Prebid config"); + } - validate_settings_for_deploy(&settings) - .expect("disabled plan overlap should remain deployable"); + /// Deploy validation reaches each built-in builder's own config type, one + /// id at a time, by planting a block no config type can deserialize. A + /// string where the block belongs fails for all of them, whatever settings + /// each one takes. + /// + /// This catches deploy validation ceasing to validate the built-ins. It + /// cannot catch a builder deleted from `BUILT_IN_BUILDERS`, because the + /// loop below reads the same constant the validation walks, and no independent + /// list of the built-ins exists in the crate. + #[test] + fn deploy_validation_reaches_every_built_in_builder() { + for builder in crate::integrations::builders() + .iter() + .filter(|builder| builder.supplies_integration()) + { + let name = builder + .module_name() + .expect("every built-in page integration names its module"); + let section = builder + .section() + .expect("every built-in page integration has a section"); + let mut settings = valid_settings(); + settings + .insert_module_config( + section, + name, + &serde_json::json!({ "no_such_setting": true }), + ) + .expect("should insert the planted table"); + + assert!( + validate_settings_for_deploy(&settings).is_err(), + "deploy validation should reach the `{name}` builder and reject its planted config" + ); + } } + /// Every built-in page integration refuses a setting it does not know, so + /// a misspelt key in its block fails deploy validation naming the + /// integration and the key, rather than being ignored. #[test] - fn deploy_validation_accepts_typed_prebid_bundle_build_table() { - let settings = valid_settings(); + fn every_integration_rejects_a_setting_it_does_not_know() { + for builder in crate::integrations::builders() + .iter() + .filter(|builder| builder.supplies_integration()) + { + let name = builder + .module_name() + .expect("every built-in page integration names its module"); + let section = builder + .section() + .expect("every built-in page integration has a section"); + let mut settings = valid_settings(); + settings + .insert_module_config( + section, + name, + &serde_json::json!({ "no_such_setting": true }), + ) + .expect("should insert the planted table"); - validate_settings_for_deploy(&settings) - .expect("test config with typed Prebid bundle build table should validate"); + let error = match validate_settings_for_deploy(&settings) { + Ok(()) => panic!("`{name}` should refuse a setting it does not know"), + Err(error) => format!("{error:?}"), + }; + let written = crate::module_name::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}" + ); + } } #[test] - fn deploy_validation_covers_registered_integration_builders() { - let validated_ids: HashSet<&'static str> = - DEPLOY_VALIDATED_INTEGRATION_IDS.iter().copied().collect(); - let missing_ids = crate::integrations::registered_builder_ids() - .filter(|id| !validated_ids.contains(id)) - .collect::>(); + fn deploy_validation_surfaces_an_external_integration_builders_rejection() { + let extra = [IntegrationBuilder::new( + "seam-probe", + "seam-probe-crate", + build_nothing, + reject_deploy, + ) + .with_module_name("testing.seam-probe")]; + + let err = validate_settings_for_deploy_with(&valid_settings(), &extra) + .expect_err("should surface the external integration builder's rejection"); assert!( - missing_ids.is_empty(), - "deploy validation should cover all registered integration builders: {missing_ids:?}" + err.to_string().contains(EXTERNAL_REJECTION_MESSAGE), + "should keep the external builder's message intact: {err:?}" ); } + /// A builder's own rules run when the settings load with that builder, so + /// a deployment that composes it is held to them at startup. #[test] - fn deploy_validation_rejects_invalid_osano_config() { - let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "osano", - &serde_json::json!({ "enabled": true, "typo": true }), - ) - .expect("should insert Osano config"); + fn runtime_validation_surfaces_an_external_integration_builders_rejection() { + let extra = [IntegrationBuilder::new( + "seam-probe", + "seam-probe-crate", + build_nothing, + reject_deploy, + ) + .with_module_name("testing.seam-probe")]; - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject invalid Osano config during deploy validation"); - let error_text = format!("{err:?}"); + validate_settings_for_runtime(&valid_settings()) + .expect("the settings should pass without the external builder"); + + let err = validate_settings_for_runtime_with(&valid_settings(), &extra) + .expect_err("should surface the external integration builder's rejection"); assert!( - error_text.contains("osano") || error_text.contains("typo"), - "error should mention Osano or the invalid field: {err:?}" + err.to_string().contains(EXTERNAL_REJECTION_MESSAGE), + "should keep the external builder's message intact: {err:?}" ); } + fn module_key_is_in_use(table: &serde_json::Map) -> bool { + table.get("lock").and_then(serde_json::Value::as_bool) == Some(true) + } + + const MODULE_SECRETS: &[crate::integrations::ModuleSecretSetting] = + &[crate::integrations::ModuleSecretSetting { + path: &["spare", "key_name"], + in_use: module_key_is_in_use, + }]; + + /// A builder a deployment added, whose module names a secret in its own + /// table. + fn module_with_a_secret() -> IntegrationBuilder { + IntegrationBuilder::new( + "probe", + "example-crate", + crate::integrations::registry_test_support::probe_registration, + crate::integrations::registry_test_support::validate_nothing, + ) + .with_module_name("testing.probe") + .with_secret_settings(MODULE_SECRETS) + } + #[test] - fn deploy_validation_rejects_invalid_datadome_test_bypass() { - for (enable_protection, name, expected_message) in [ - (false, "datadome_test_bypass", "requires enable_protection"), - (true, "", "credential_secret_name"), + fn deploy_validation_refuses_a_module_s_secret_setting_in_use_that_names_no_key() { + for (table, expected) in [ + ( + serde_json::json!({ "lock": true }), + Some("testing.probe.spare.key_name"), + ), + ( + serde_json::json!({ "lock": true, "spare": { "key_name": " " } }), + Some("testing.probe.spare.key_name"), + ), + ( + serde_json::json!({ "lock": true, "spare": { "key_name": "module_key" } }), + None, + ), + // A setting the table does not put to use is not checked. + (serde_json::json!({ "lock": false }), None), ] { let mut settings = valid_settings(); settings - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": enable_protection, - "server_side_key_secret_name": "datadome_server_side_key", - "protection_test_bypass": { - "enabled": true, - "credential_secret_name": name, - }, - }), - ) - .expect("should insert DataDome config"); + .insert_module_config("testing", "testing.probe", &table) + .expect("should insert the module's table"); - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject invalid DataDome test bypass"); - assert!( - format!("{err:?}").contains(expected_message), - "error should mention the invalid bypass setting: {err:?}" - ); + let result = validate_settings_for_deploy_with(&settings, &[module_with_a_secret()]); + + match expected { + Some(path) => { + let error = result.expect_err("should refuse a setting that names no key"); + assert!( + format!("{error:?}").contains(path), + "should name the setting for {table}: {error:?}" + ); + } + None => result.unwrap_or_else(|error| { + panic!("should accept {table}: {error:?}"); + }), + } + } + } + + const PLAN_RULE_MESSAGE: &str = "probe module refuses an enabled auction"; + + fn refuse_an_enabled_auction( + _settings: &Settings, + plan: &crate::auction::AuctionPlan, + ) -> Result<(), Report> { + if plan.enabled() { + return Err(Report::new(TrustedServerError::Configuration { + message: PLAN_RULE_MESSAGE.to_owned(), + })); } + Ok(()) } #[test] - fn validate_rejects_invalid_disabled_js_asset_proxy_assets() { + fn deploy_validation_runs_a_builder_s_check_against_the_auction_plan() { + let extra = [IntegrationBuilder::new( + "probe", + "example-crate", + crate::integrations::registry_test_support::probe_registration, + crate::integrations::registry_test_support::validate_nothing, + ) + .with_module_name("testing.probe") + .with_plan_validator(refuse_an_enabled_auction)]; let mut settings = valid_settings(); - settings.integrations.insert( - JS_ASSET_PROXY_INTEGRATION_ID.to_string(), - serde_json::json!({ - "enabled": false, - "assets": [{ - "path": "bad path", - "origin_url": "not-a-url", - "proxy": "disabled" - }] - }), + + settings.auction.enabled = false; + validate_settings_for_deploy_with(&settings, &extra) + .expect("should accept a plan the module's rule allows"); + + settings.auction.enabled = true; + let error = validate_settings_for_deploy_with(&settings, &extra) + .expect_err("should refuse a plan the module's rule does not allow"); + assert!( + error.to_string().contains(PLAN_RULE_MESSAGE), + "should keep the module's message intact: {error:?}" ); + validate_settings_for_deploy(&settings) + .expect("should apply no such rule without the module's builder"); + } + + #[test] + fn deploy_validation_checks_no_secret_setting_without_the_module_s_builder() { + let mut settings = valid_settings(); + settings + .insert_module_config( + "testing", + "testing.probe", + &serde_json::json!({ "lock": true }), + ) + .expect("should insert the module's table"); + + validate_settings_for_deploy(&settings) + .expect("should check nothing for a module that declared nothing to this validation"); + } + + #[test] + fn validate_rejects_invalid_js_asset_proxy_assets() { + let mut settings = valid_settings(); + settings + .insert_module_config( + "proxy", + "js_asset_proxy", + &serde_json::json!({ + "assets": [{ + "path": "bad path", + "origin_url": "not-a-url", + "proxy": "disabled" + }] + }), + ) + .expect("should insert the asset inventory"); let err = validate_settings_for_deploy(&settings) - .expect_err("should reject invalid disabled asset inventory"); + .expect_err("should reject an invalid asset inventory"); let message = err.to_string(); assert!( message.contains(JS_ASSET_PROXY_INTEGRATION_ID), @@ -1885,15 +1842,16 @@ password = "production-admin-password-32-bytes" fn validate_trait_reports_deploy_errors() { let mut settings = valid_settings(); settings.auction.enabled = true; - settings.auction.providers = - crate::auction::AuctionConfig::legacy_provider_map(&["missing-provider"]); - settings - .auction - .providers - .values_mut() - .next() - .expect("should have provider") - .protocol = "unsupported".to_string(); + settings.demand = crate::provider_table::ProviderList::new( + vec!["missing_provider".to_string()], + std::collections::BTreeMap::from([( + "missing_provider".to_string(), + serde_json::Map::from_iter([( + "implementation".to_string(), + serde_json::json!("no_such_implementation"), + )]), + )]), + ); let app_config = TrustedServerAppConfig { settings }; let err = app_config @@ -1901,8 +1859,8 @@ password = "production-admin-password-32-bytes" .expect_err("should reject invalid auction provider"); assert!( - err.to_string().contains("missing-provider"), - "validation error should mention invalid provider" + err.to_string().contains("no_such_implementation"), + "validation error should name the implementation this build does not have" ); } } diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 48cce20546..2661230248 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -12,9 +12,10 @@ use error_stack::{Report, ResultExt as _}; use crate::config::TrustedServerAppConfig; use crate::error::TrustedServerError; +use crate::integrations::IntegrationBuilder; use crate::permissions::{PermissionPolicy, PolicyReference, held_policy, load_policy}; use crate::platform::{PlatformSecretStore, PolicyOrigin, StoreName}; -use crate::secret_resolution::resolve_secret_references; +use crate::secret_resolution::resolve_secret_references_with; use crate::settings::{PermissionsConfig, Settings}; /// Canonical logical secret store used by Trusted Server app-config secrets. @@ -34,7 +35,8 @@ pub const DEFAULT_CONFIG_STORE_ID: &str = env!("TRUSTED_SERVER_DEFAULT_CONFIG_ST /// process-environment overrides. pub const CONFIG_BLOB_KEY: &str = DEFAULT_CONFIG_STORE_ID; -/// Reconstruct runtime [`Settings`] from a serialized config blob envelope. +/// Reconstruct runtime [`Settings`] from a serialized config blob envelope, +/// validating against the built-in integrations only. /// /// Secret references are resolved after envelope verification and before /// deserialization. The envelope data itself is never mutated or rewritten. @@ -43,6 +45,10 @@ pub const CONFIG_BLOB_KEY: &str = DEFAULT_CONFIG_STORE_ID; /// keep only its name and digest in `[permissions]`. A policy source is not /// read for a digest whose rules this instance already holds. /// +/// A deployment that composes builders of its own calls +/// [`settings_from_config_blob_with`] instead, so a `[demand]` or +/// `[ad-server]` name one of them supplies is not refused here. +/// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when the envelope cannot be @@ -54,6 +60,33 @@ pub fn settings_from_config_blob( secret_store: &dyn PlatformSecretStore, default_secret_store_name: &StoreName, policy_origin: PolicyOrigin<'_>, +) -> Result> { + settings_from_config_blob_with( + envelope_json, + secret_store, + default_secret_store_name, + policy_origin, + &[], + ) +} + +/// Reconstruct runtime [`Settings`] from a serialized config blob envelope, +/// validating against the built-in integrations followed by +/// `extra_integrations`, with the permission policy read from +/// `policy_origin` as [`settings_from_config_blob`] reads it. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the envelope cannot be +/// parsed, fails integrity verification, secret resolution fails, resolved +/// settings are invalid, or the settings name no permission policy or one +/// that cannot be read, is not the file that was pushed, or is not valid. +pub fn settings_from_config_blob_with( + envelope_json: &str, + secret_store: &dyn PlatformSecretStore, + default_secret_store_name: &StoreName, + policy_origin: PolicyOrigin<'_>, + extra_integrations: &[IntegrationBuilder], ) -> Result> { let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { Report::new(TrustedServerError::Configuration { @@ -68,15 +101,21 @@ pub fn settings_from_config_blob( .attach(error.to_string()) })?; + // The modules' own secret settings are found through the builders the + // settings are validated against, so a module a deployment added has its + // secrets looked up here as a stock one does. + let builders = crate::integrations::all_builders(extra_integrations).collect::>(); let mut data = envelope.into_data(); remove_inactive_secret_references(&mut data); - resolve_secret_references::( + crate::module_secrets::clear_unused(&mut data, &builders); + resolve_secret_references_with::( &mut data, secret_store, default_secret_store_name, + crate::module_secrets::secret_fields(&builders), )?; let mut settings = Settings::from_json_value(data)?; - crate::config::validate_settings_for_runtime(&settings)?; + crate::config::validate_settings_for_runtime_with(&settings, extra_integrations)?; let policy = load_domain_policy(&mut settings.permissions, policy_origin)?; log_permission_baseline(&policy); settings.attach_permission_policy(policy); @@ -181,38 +220,6 @@ fn remove_inactive_secret_references(data: &mut serde_json::Value) { } } } - - let Some(datadome) = data - .pointer_mut("/integrations/datadome") - .and_then(serde_json::Value::as_object_mut) - else { - return; - }; - let integration_enabled = - datadome.get("enabled").and_then(serde_json::Value::as_bool) == Some(true); - let protection_enabled = integration_enabled - && datadome - .get("enable_protection") - .and_then(serde_json::Value::as_bool) - == Some(true); - if !protection_enabled { - datadome.remove("server_side_key_secret_name"); - } - - let bypass_enabled = protection_enabled - && datadome - .get("protection_test_bypass") - .and_then(serde_json::Value::as_object) - .and_then(|bypass| bypass.get("enabled")) - .and_then(serde_json::Value::as_bool) - == Some(true); - if !bypass_enabled - && let Some(bypass) = datadome - .get_mut("protection_test_bypass") - .and_then(serde_json::Value::as_object_mut) - { - bypass.remove("credential_secret_name"); - } } fn json_bool_or_string_is_true(value: Option<&serde_json::Value>) -> bool { @@ -224,7 +231,6 @@ fn json_bool_or_string_is_true(value: Option<&serde_json::Value>) -> bool { mod tests { use super::*; use crate::ec::module::{HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_KEY}; - use crate::integrations::didomi::DidomiIntegrationConfig; use crate::platform::{PlatformError, StoreId}; use crate::redacted::Redacted; use crate::settings::{ @@ -289,8 +295,6 @@ mod tests { let value = match key { "unit-test-proxy-secret" => "unit-test-proxy-secret-32-bytes-ok", "tinybird-token-key" => "resolved-tinybird-token", - "datadome-server-key" => "resolved-datadome-server-key", - "datadome-bypass-key" => "resolved-datadome-bypass-credential-32-bytes", "access_key_id" | "s3-access-key" => "AKIAIOSFODNN7EXAMPLE", "secret_access_key" | "s3-secret-key" => "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "s3-session-key" => "resolved-session-token", @@ -351,29 +355,194 @@ mod tests { ) } - fn settings_with_browser_bidder_overlap(auction_enabled: bool) -> Settings { + /// 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: &serde_json::Map, + ) -> Result< + std::sync::Arc, + Report, + > { + Ok(std::sync::Arc::new( + crate::auction::test_support::adserver_fixture::FixtureAdServer::new(name, 500), + )) + } + + /// Holds the one key a deployment's module names, and refuses the secret + /// itself as a key, so a leaf looked up twice fails. + struct ModuleSecretStore; + + impl PlatformSecretStore for ModuleSecretStore { + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + match key { + "module-key" => Ok(b"resolved-module-secret".to_vec()), + "resolved-module-secret" | "unused-module-key" => { + Err(Report::new(PlatformError::SecretStore)) + } + _ => EchoSecretStore.get_bytes(store_name, key), + } + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } + } + + fn module_lock_is_on(table: &serde_json::Map) -> bool { + table.get("lock").and_then(serde_json::Value::as_bool) == Some(true) + } + + const MODULE_SECRETS: &[crate::integrations::ModuleSecretSetting] = + &[crate::integrations::ModuleSecretSetting { + path: &["key_name"], + in_use: module_lock_is_on, + }]; + + /// A builder a deployment added, whose module names a secret in its own + /// table. + fn module_with_a_secret() -> IntegrationBuilder { + IntegrationBuilder::new( + "probe", + "example-crate", + crate::integrations::registry_test_support::probe_registration, + crate::integrations::registry_test_support::validate_nothing, + ) + .with_module_name("testing.probe") + .with_secret_settings(MODULE_SECRETS) + } + + fn load_with_module(settings: &Settings) -> Settings { + settings_from_config_blob_with( + &envelope_json(settings), + &ModuleSecretStore, + &StoreName::from("trusted_server_secrets"), + PolicyOrigin::Blob, + &[module_with_a_secret()], + ) + .expect("should load settings with the module's builder") + } + + #[test] + fn the_load_looks_up_a_secret_a_deployment_s_module_declares() { let mut settings = test_settings(); - settings.proxy.allowed_domains = vec!["*.example".to_string()]; - settings.auction.enabled = auction_enabled; - settings.auction.providers = crate::auction::AuctionConfig::legacy_provider_map(&["pbs"]); - settings.auction.bidders.insert( - "exampleBidder" - .parse() - .expect("should parse server-side bidder"), - crate::auction::BidderRouteConfig { - provider: "pbs".parse().expect("should parse provider"), - }, + settings + .insert_module_config( + "testing", + "testing.probe", + &serde_json::json!({ "lock": true, "key_name": "module-key" }), + ) + .expect("should insert the module's table"); + + let loaded = load_with_module(&settings); + + assert_eq!( + loaded.section_table("testing", "probe").get("key_name"), + Some(&serde_json::json!("resolved-module-secret")), + "should hold the secret where the table held the name of its key" ); - let mut prebid = settings - .integration_config::("prebid") - .expect("should parse Prebid config") - .expect("should have enabled Prebid config"); - prebid.client_side_bidders = vec!["exampleBidder".to_string()]; + } + + #[test] + fn the_load_clears_a_declared_secret_the_module_s_table_does_not_use() { + let mut settings = test_settings(); settings - .integrations - .insert_config("prebid", &prebid) - .expect("should replace Prebid config"); + .insert_module_config( + "testing", + "testing.probe", + &serde_json::json!({ "lock": false, "key_name": "unused-module-key" }), + ) + .expect("should insert the module's table"); + + let loaded = load_with_module(&settings); + + assert_eq!( + loaded.section_table("testing", "probe").get("key_name"), + None, + "should clear a key name nothing uses, where looking it up would fail" + ); + } + + #[test] + fn a_table_s_key_name_is_left_as_written_without_its_module_s_builder() { + let mut settings = test_settings(); settings + .insert_module_config( + "testing", + "testing.probe", + &serde_json::json!({ "lock": true, "key_name": "module-key" }), + ) + .expect("should insert the module's table"); + + let loaded = settings_from_config_blob( + &envelope_json(&settings), + &ModuleSecretStore, + &StoreName::from("trusted_server_secrets"), + PolicyOrigin::Blob, + ) + .expect("should load settings without the module's builder"); + + assert_eq!( + loaded.section_table("testing", "probe").get("key_name"), + Some(&serde_json::json!("module-key")), + "should look nothing up for a table whose module declared nothing to this load" + ); + } + + /// The settings are validated as they load, so a deployment that composes + /// a builder hands it to the load. Without it the load refuses the ad + /// server that builder supplies, and with it the settings load. + #[test] + fn the_load_accepts_an_external_ad_server_only_when_given_its_builder() { + let mut settings = test_settings(); + settings.adserver = crate::provider_table::ProviderChoice::new( + Some("example".to_string()), + std::collections::BTreeMap::from([("example".to_string(), serde_json::Map::new())]), + ); + let envelope = envelope_json(&settings); + let extra = [ + IntegrationBuilder::implementations("example-adserver", "example-crate") + .with_adserver(&EXTERNAL_ADSERVER), + ]; + + let error = + load_settings(&envelope).expect_err("built-ins alone should not know this ad server"); + assert!( + error.to_string().contains("example"), + "should name the ad server: {error:?}" + ); + + let loaded = settings_from_config_blob_with( + &envelope, + &EchoSecretStore, + &StoreName::from("trusted_server_secrets"), + PolicyOrigin::Blob, + &extra, + ) + .expect("should load settings naming the external builder's ad server"); + assert_eq!( + loaded.adserver.selected().first().copied(), + Some("example"), + "the loaded settings should still select the external ad server" + ); } #[test] @@ -397,33 +566,45 @@ mod tests { ); } + /// The settings of an example module, with a flag that defaults to off. + #[derive(Debug, serde::Deserialize, serde::Serialize, validator::Validate)] + #[serde(deny_unknown_fields)] + struct ExampleModuleSettings { + #[serde(default)] + opted_in: bool, + endpoint: String, + } + + impl crate::settings::IntegrationConfig for ExampleModuleSettings {} + #[test] - fn didomi_geo_query_parameters_survive_blob_round_trip() { + fn a_module_s_table_survives_the_blob_round_trip() { let mut original = test_settings(); original - .integrations - .insert_config( - "didomi", - &DidomiIntegrationConfig { - enabled: true, - geo_query_parameters: true, - proxy_path: None, - sdk_origin: "https://sdk.example.com".to_string(), - api_origin: "https://api.example.com".to_string(), + .insert_module_config( + "example", + "example.notice", + &ExampleModuleSettings { + opted_in: true, + endpoint: "https://api.example.com".to_string(), }, ) - .expect("should insert Didomi configuration"); + .expect("should insert the example module's table"); let reconstructed = load_settings(&envelope_json(&original)).expect("should reconstruct settings"); let config = reconstructed - .integration_config::("didomi") - .expect("should read Didomi configuration") - .expect("should enable Didomi"); + .module_config::("example.notice") + .expect("should read the example module's table") + .expect("should still select the example module"); assert!( - config.geo_query_parameters, - "should preserve Didomi geo opt-in" + config.opted_in, + "should preserve a flag the table set away from its default" + ); + assert_eq!( + config.endpoint, "https://api.example.com", + "should preserve the table's other settings" ); } @@ -434,21 +615,6 @@ mod tests { original.tinybird.api_host = "api.example.com".to_string(); original.tinybird.auction_token_secret = Some(Redacted::new("tinybird-token-key".to_string())); - original - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": true, - "server_side_key_secret_name": "datadome-server-key", - "protection_test_bypass": { - "enabled": true, - "credential_secret_name": "datadome-bypass-key", - }, - }), - ) - .expect("should configure DataDome references"); let mut route = ProxyAssetRoute::new( "/assets/", "https://examplebucket.s3.us-east-1.amazonaws.com", @@ -484,30 +650,6 @@ mod tests { .map(String::as_str), Some("resolved-tinybird-token") ); - let datadome = reconstructed - .integration_config::("datadome") - .expect("should parse DataDome config") - .expect("should enable DataDome"); - assert_eq!( - datadome - .server_side_key_secret_name - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-datadome-server-key") - ); - let bypass = datadome - .protection_test_bypass - .as_ref() - .expect("should configure bypass"); - assert_eq!( - bypass - .credential_secret_name - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-datadome-bypass-credential-32-bytes") - ); let auth = reconstructed.proxy.asset_routes[0] .auth .as_ref() @@ -846,21 +988,6 @@ mod tests { let mut original = test_settings(); original.tinybird.auction_token_secret = Some(Redacted::new("unused-tinybird-key".to_string())); - original - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": false, - "server_side_key_secret_name": "unused-datadome-key", - "protection_test_bypass": { - "enabled": false, - "credential_secret_name": "unused-bypass-key", - }, - }), - ) - .expect("should configure inactive references"); original .ec .partners @@ -876,50 +1003,44 @@ mod tests { assert!(reconstructed.tinybird.auction_token_secret.is_none()); assert!(reconstructed.ec.partners[0].ts_pull_token.is_none()); - let datadome = reconstructed - .integration_config::("datadome") - .expect("should parse inactive DataDome config") - .expect("client-side DataDome remains enabled"); - assert!(datadome.server_side_key_secret_name.is_none()); - assert!( - datadome - .protection_test_bypass - .as_ref() - .is_some_and(|bypass| bypass.credential_secret_name.is_none()) - ); } + /// A table for a module its section does not select is refused, and its + /// secret references are dropped before resolution, so the operator reads + /// the table's own fault rather than a secret-store failure that follows + /// from it. #[test] - fn omitted_datadome_enabled_does_not_resolve_stale_protection_references() { - let mut original = test_settings(); - original - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enable_protection": true, - "server_side_key_secret_name": "unused-datadome-key", - "protection_test_bypass": { - "enabled": true, - "credential_secret_name": "unused-bypass-key", - }, + fn an_unselected_module_s_table_is_refused_without_resolving_its_secrets() { + let original = test_settings(); + let mut data = serde_json::to_value(&original).expect("should serialize settings to JSON"); + data.as_object_mut() + .expect("settings should serialize as an object") + .insert( + "testing".to_owned(), + serde_json::json!({ + "probe": { "lock": true, "key_name": "unused-module-key" }, }), - ) - .expect("should configure disabled DataDome references"); + ); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + let envelope = serde_json::to_string(&envelope).expect("should serialize envelope"); - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), + let error = settings_from_config_blob_with( + &envelope, + &ModuleSecretStore, + &StoreName::from("trusted_server_secrets"), PolicyOrigin::Blob, + &[module_with_a_secret()], ) - .expect("should skip stale DataDome protection references"); + .expect_err("should refuse a table its section does not select"); + let rendered = format!("{error:?}"); assert!( - reconstructed - .integration_config::("datadome") - .expect("should parse disabled DataDome config") - .is_none() + rendered.contains("[testing] selects no module"), + "should name the section and what it is missing: {rendered}" + ); + assert!( + !rendered.contains("unused-module-key"), + "should not have tried to resolve the stale secret reference: {rendered}" ); } @@ -1008,20 +1129,6 @@ mod tests { ); } - #[test] - fn runtime_blob_rejects_enabled_browser_bidder_ownership_conflict() { - let original = settings_with_browser_bidder_overlap(true); - let error = load_settings(&envelope_json(&original)) - .expect_err("should reject enabled browser bidder ownership conflict"); - - assert!(error.to_string().contains("exampleBidder")); - assert!( - error - .to_string() - .contains("both client-side and server-side") - ); - } - #[test] fn runtime_validation_rejects_short_resolved_passphrase() { let mut settings = test_settings(); @@ -1100,14 +1207,6 @@ mod tests { ); } - #[test] - fn runtime_blob_accepts_disabled_browser_bidder_ownership_overlap() { - let original = settings_with_browser_bidder_overlap(false); - - load_settings(&envelope_json(&original)) - .expect("runtime should accept disabled browser bidder ownership overlap"); - } - #[test] fn tampered_blob_hash_is_rejected() { let mut envelope: BlobEnvelope = diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 8a20c72965..0c88c8d2c2 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -1282,17 +1282,14 @@ pub fn evaluate_ad_stack_gate(input: AdStackGateInput) -> AdStackGateResult { #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use std::str::FromStr as _; use edgezero_core::body::Body as EdgeBody; use http::Request; use super::*; - use crate::auction::plan::{ - AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, - }; + use crate::auction::plan::AuctionPlan; use crate::auction::routing::route_auction; + use crate::auction::test_support::{demand_table, plan_config}; use crate::auction::types::{AuctionRequest, PublisherInfo, UserInfo}; #[test] @@ -2378,25 +2375,12 @@ mod tests { slot.providers.prebid = Some(PrebidSlotParams { bidders: HashMap::new(), }); - let plan = AuctionPlan::compile(AuctionPlanConfig { - timeout_ms: 900, - providers: BTreeMap::from([( - ProviderId::from_str("pbs-primary").expect("should parse provider ID"), - ProviderConfig { - protocol: "openrtb-2.6".to_string(), - profile: "prebid-server".to_string(), - endpoint: "https://pbs.example.test/openrtb".to_string(), - timeout_ms: None, - routing: RoutingMode::Explicit, - notifications: NotificationConfig::default(), - profile_config: serde_json::json!({}), - }, - )]), - bidders: BTreeMap::new(), - mediator: None, - request_signing: None, - }) - .expect("should compile plan"); + let mut config = plan_config(vec![( + "stored_primary", + demand_table("auction.fixture", "https://stored.example.test/openrtb"), + )]); + config.timeout_ms = 900; + let plan = AuctionPlan::compile(config).expect("should compile plan"); let auction_request = AuctionRequest { id: "auction-1".to_string(), slots: vec![slot.to_ad_slot()], @@ -2421,13 +2405,13 @@ mod tests { assert_eq!(routed.inputs().len(), 1); assert!(routed.inputs()[0].slots()[0].allows_stored_fallback_without_candidates()); - assert_eq!(routed.inputs()[0].slots()[0].prebid_zone(), Some("header")); + assert_eq!(routed.inputs()[0].slots()[0].zone(), Some("header")); assert!( routed.inputs()[0].slots().iter().all(|slot| { !slot .bidder_params() .keys() - .any(|bidder| bidder.as_str() == "pbs-primary") + .any(|bidder| bidder.as_str() == "stored_primary") }), "provider ID should not reach client-controlled bidder input" ); diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index 6f017647f3..950942ecc2 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -127,6 +127,12 @@ impl DeviceSignals { /// classification the Edge Cookie gate needs, not a full device-detection /// result such as make, model, OS version, or screen size. A richer device /// model for the ad request is a separate concern. +/// Uses `#[async_trait(?Send)]` for the same reason as +/// [`PlatformHttpClient`](crate::platform::PlatformHttpClient): the trait +/// object stays `Send + Sync` so it can be shared and run multi-threaded, +/// while the future it returns is pinned to one thread because the host SDKs +/// produce `!Send` futures on wasm32. +#[async_trait::async_trait(?Send)] pub trait DeviceModule: Send + Sync { /// Returns the stable identifier for this module, used in configuration /// and logs. @@ -139,7 +145,15 @@ pub trait DeviceModule: Send + Sync { /// Device signals gate identity operations and must always yield a value, /// so this is infallible: a module that cannot determine a signal returns /// the unknown variant rather than failing the request. - fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals; + /// Asynchronous because a device module may reach a backend, a + /// key-value store or a secret to classify a request, and a module that + /// cannot make those calls cannot be written at all. The built-in + /// User-Agent provider does no I/O and returns immediately. + async fn detect( + &self, + request_info: &dyn RequestInfo, + services: &crate::platform::RuntimeServices, + ) -> DeviceSignals; /// The permissions this module's data use requires. /// @@ -167,17 +181,23 @@ impl BuiltinDeviceModule { } } +#[async_trait::async_trait(?Send)] impl DeviceModule for BuiltinDeviceModule { fn id(&self) -> &'static str { "builtin" } - fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + async fn detect( + &self, + request_info: &dyn RequestInfo, + _services: &crate::platform::RuntimeServices, + ) -> DeviceSignals { DeviceSignals::derive_ua_only(request_info.user_agent()) } } -/// Selects the device module named by the `[device] module` selector. +/// The device module core or the adapter supplies for the `[device] module` +/// selector. /// /// Returns the built-in User-Agent-only module unless the `fastly` selector is /// set, in which case it builds the host-specific module through the @@ -185,10 +205,12 @@ impl DeviceModule for BuiltinDeviceModule { /// module is selected, so device classification itself reads no host signals /// by default (see [`BuiltinDeviceModule`] for the host-neutral default). The /// Fastly entry point still reads the TLS and HTTP/2 signals on every request to -/// build the host-signal service and client info. A -/// selected-but-unknown module is rejected at startup by -/// [`DeviceConfig::validate_module_selection`](crate::settings::DeviceConfig::validate_module_selection), -/// so this falls back to the built-in module for that case. +/// build the host-signal service and client info. +/// +/// A module a registration supplies is resolved by the integration registry, +/// which refuses a name no module supplies when it is built. A caller uses +/// this only when the registry resolved no module, so it never sees such a +/// name. #[must_use] pub fn build_device_module( settings: &Settings, @@ -774,15 +796,20 @@ mod tests { ); } - #[test] - fn builtin_device_module_is_ua_only() { + #[tokio::test] + async fn builtin_device_module_is_ua_only() { let module = BuiltinDeviceModule::new(); assert_eq!(module.id(), "builtin"); // The built-in module classifies from the User-Agent in the request // info passed to `detect` alone, recording no host signal. let request_info = request_info_with_ua(CHROME_MAC_UA); - let signals = module.detect(&request_info); + let signals = module + .detect( + &request_info, + &crate::platform::test_support::noop_services(), + ) + .await; assert_eq!( signals, DeviceSignals::derive_ua_only(CHROME_MAC_UA), @@ -809,12 +836,17 @@ mod tests { /// selection logic can be tested in core without the Fastly module crate. struct StubFastlyModule; + #[async_trait::async_trait(?Send)] impl DeviceModule for StubFastlyModule { fn id(&self) -> &'static str { "fastly" } - fn detect(&self, _request_info: &dyn RequestInfo) -> DeviceSignals { + async fn detect( + &self, + _request_info: &dyn RequestInfo, + _services: &crate::platform::RuntimeServices, + ) -> DeviceSignals { DeviceSignals::derive_ua_only("") } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index f31693f421..267f1e4a91 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -39,7 +39,14 @@ use super::{EcKvSnapshot, EidSyncSource, current_timestamp, log_id}; /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. -pub fn ec_finalize_response( +/// +/// `services` are the request's runtime services, handed to the selected Edge +/// Cookie module when an orphaned identifier is replaced. +#[allow( + clippy::too_many_arguments, + reason = "orphan recovery asks the selected module for a replacement identifier, which needs the request's services on top of the finalize inputs" +)] +pub async fn ec_finalize_response( settings: &Settings, ec_context: &mut EcContext, kv: Option<&KvIdentityGraph>, @@ -47,6 +54,7 @@ pub fn ec_finalize_response( eids_cookie: Option<&str>, sharedid_cookie: Option<&str>, response: &mut Response, + services: &crate::platform::RuntimeServices, ) { // Apply any response headers the active module asked for during // generation (for example to request more client evidence). This is empty @@ -144,8 +152,9 @@ pub fn ec_finalize_response( && ec_context.recovery_eligible() { confirm_then_recover_orphaned_ec( - settings, ec_context, graph, &kv_key, &updates, response, - ); + settings, ec_context, graph, &kv_key, &updates, response, services, + ) + .await; } } @@ -306,13 +315,14 @@ fn gate_eid_updates_by_permissions( /// identifier, and its row is created only when no row already holds its key. /// Every exit that does not rotate leaves a failed snapshot bound to the /// orphan's key. -fn recover_orphaned_ec( +async fn recover_orphaned_ec( settings: &Settings, ec_context: &mut EcContext, graph: &KvIdentityGraph, orphan_kv_key: &str, updates: &[super::kv::PartnerIdUpdate], response: &mut Response, + services: &crate::platform::RuntimeServices, ) { // A deployment with no module selected has nothing to rotate to. let Some(module) = ec_context.selected_module() else { @@ -324,7 +334,7 @@ fn recover_orphaned_ec( // identifier. Whether the client IP is needed is that module's decision. const MAX_RECOVERY_ATTEMPTS: usize = 5; for _attempt in 0..MAX_RECOVERY_ATTEMPTS { - let ec_id = match ec_context.candidate_id(module.as_ref()) { + let ec_id = match ec_context.candidate_id(module.as_ref(), services).await { Ok(Some(ec_id)) => ec_id, Ok(None) => { log::info!("Orphan EC recovery skipped because the module produced no identifier"); @@ -406,13 +416,14 @@ fn recover_orphaned_ec( /// the identity stays intact and recovery is retried on a later navigation; /// - neither a read failure nor a failed existence check is a miss, and neither /// rotates. -fn confirm_then_recover_orphaned_ec( +async fn confirm_then_recover_orphaned_ec( settings: &Settings, ec_context: &mut EcContext, graph: &KvIdentityGraph, kv_key: &str, updates: &[super::kv::PartnerIdUpdate], response: &mut Response, + services: &crate::platform::RuntimeServices, ) { let confirmed = graph.load_snapshot(kv_key); match confirmed { @@ -430,7 +441,10 @@ fn confirm_then_recover_orphaned_ec( } EcKvSnapshot::Missing { .. } => match graph.key_exists_confirmed(kv_key) { Ok(false) => { - recover_orphaned_ec(settings, ec_context, graph, kv_key, updates, response) + recover_orphaned_ec( + settings, ec_context, graph, kv_key, updates, response, services, + ) + .await } Ok(true) => { log::warn!( @@ -590,6 +604,7 @@ where #[cfg(test)] mod tests { + use crate::platform::test_support::noop_services; use http::HeaderValue; use base64::Engine as _; @@ -820,8 +835,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_does_not_create_a_row_for_an_unheld_identity() { + #[tokio::test] + async fn finalize_withdrawal_does_not_create_a_row_for_an_unheld_identity() { let settings = create_test_settings(); // The cookie value is chosen by the client, so a withdrawal naming an // identity this deployment never issued must not put a row in the @@ -851,7 +866,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( kv.get(&ec_id).expect("should read back").is_none(), @@ -868,8 +885,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_tombstones_a_held_identity() { + #[tokio::test] + async fn finalize_withdrawal_tombstones_a_held_identity() { let settings = create_test_settings(); let ec_id = sample_ec_id("held01"); // A TCF record refusing storage under the requires-signal floor is @@ -907,7 +924,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let (entry, _) = kv .get(&ec_id) @@ -925,8 +944,8 @@ mod tests { assert_eq!(ec_context.kv_snapshot().generation_for(&ec_id), None); } - #[test] - fn withdrawal_still_expires_the_cookie_when_the_store_is_unavailable() { + #[tokio::test] + async fn withdrawal_still_expires_the_cookie_when_the_store_is_unavailable() { // Cookie expiry is the primary enforcement, so it has to survive a // store that cannot answer at all — the case where the identity-graph // marker is exactly what goes missing. @@ -962,7 +981,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let set_cookie = get_header_str(&response, "set-cookie").unwrap_or_default(); assert!( @@ -979,8 +1000,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_clears_cookie_and_headers() { + #[tokio::test] + async fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); // A TCF record refusing storage is the withdrawal trigger, under a @@ -1013,7 +1034,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1062,8 +1085,8 @@ mod tests { } } - #[test] - fn finalize_gpc_suppresses_headers_but_keeps_the_cookie() { + #[tokio::test] + async fn finalize_gpc_suppresses_headers_but_keeps_the_cookie() { // A US-style opt-out suppresses use (headers cleared, nothing egressed) // but is never destructive: the browser cookie is not expired, so a // visitor who later withdraws the opt-out keeps their identity. @@ -1089,7 +1112,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1101,8 +1126,8 @@ mod tests { ); } - #[test] - fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { + #[tokio::test] + async fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { let settings = create_test_settings(); let active_ec = sample_ec_id("activ1"); let cookie_ec = sample_ec_id("cook1e"); @@ -1125,7 +1150,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1137,8 +1164,8 @@ mod tests { ); } - #[test] - fn finalize_returning_user_sets_no_header_or_cookie() { + #[tokio::test] + async fn finalize_returning_user_sets_no_header_or_cookie() { let settings = create_test_settings(); let ec_id = sample_ec_id("mtch01"); let mut ec_context = make_context( @@ -1160,7 +1187,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1172,8 +1201,8 @@ mod tests { ); } - #[test] - fn finalize_generated_ec_without_kv_skips_cookie_and_header() { + #[tokio::test] + async fn finalize_generated_ec_without_kv_skips_cookie_and_header() { let settings = create_test_settings(); let generated_ec = sample_ec_id("gen123"); let mut ec_context = make_context( @@ -1195,7 +1224,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1207,8 +1238,8 @@ mod tests { ); } - #[test] - fn finalize_rotates_orphaned_cookie_to_new_backed_ec() { + #[tokio::test] + async fn finalize_rotates_orphaned_cookie_to_new_backed_ec() { let settings = create_test_settings(); let orphaned_ec = sample_ec_id("orphn1"); let consent = ConsentContext { @@ -1237,7 +1268,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let replacement = ec_context.ec_value().expect("should rotate orphan"); assert_ne!(replacement, orphaned_ec); @@ -1254,8 +1287,8 @@ mod tests { ); } - #[test] - fn finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates() { + #[tokio::test] + async fn finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates() { // Regression test: this request has real EID data (a configured // partner and a captured client EID), but TCF Purpose 4 denial gates // it away, emptying the update list. That must not also suppress the @@ -1300,7 +1333,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let replacement = ec_context.ec_value().expect( "should rotate the orphan even though Purpose 4 denial emptied the gated update list", @@ -1320,8 +1355,8 @@ mod tests { ); } - #[test] - fn valid_marker_with_unread_snapshot_defers_orphan_recovery() { + #[tokio::test] + async fn valid_marker_with_unread_snapshot_defers_orphan_recovery() { let settings = create_test_settings(); let orphaned_ec = sample_ec_id("orphn2"); let consent = ConsentContext { @@ -1355,7 +1390,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_eq!( ec_context.ec_value(), @@ -1372,8 +1409,8 @@ mod tests { ); } - #[test] - fn finalize_returning_user_subresource_does_not_persist_eid_updates() { + #[tokio::test] + async fn finalize_returning_user_subresource_does_not_persist_eid_updates() { let settings = create_test_settings(); let ec_id = sample_ec_id("subeid"); let graph = KvIdentityGraph::in_memory("test_store"); @@ -1399,7 +1436,9 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + &noop_services(), + ) + .await; let (stored, _) = graph .get(&ec_id) @@ -1411,8 +1450,8 @@ mod tests { ); } - #[test] - fn finalize_navigation_routes_persist_returning_user_eid_updates() { + #[tokio::test] + async fn finalize_navigation_routes_persist_returning_user_eid_updates() { for (source, suffix, cookie_id) in [ (EidSyncSource::Navigation, "naveid", "navigation-cookie-id"), (EidSyncSource::PageBids, "spaeid", "page-bids-cookie-id"), @@ -1443,7 +1482,9 @@ mod tests { None, Some(cookie_id), &mut response, - ); + &noop_services(), + ) + .await; let (stored, _) = graph .get(&ec_id) @@ -1457,8 +1498,8 @@ mod tests { } } - #[test] - fn finalize_generated_ec_persists_eid_updates() { + #[tokio::test] + async fn finalize_generated_ec_persists_eid_updates() { let settings = create_test_settings(); let ec_id = sample_ec_id("geneid"); let graph = KvIdentityGraph::in_memory("test_store"); @@ -1492,7 +1533,9 @@ mod tests { None, Some("generated-cookie-id"), &mut response, - ); + &noop_services(), + ) + .await; let (stored, _) = graph .get(&ec_id) @@ -1600,8 +1643,8 @@ mod tests { ); } - #[test] - fn finalize_auction_transient_miss_still_persists_eid_updates() { + #[tokio::test] + async fn finalize_auction_transient_miss_still_persists_eid_updates() { // `/auction` saves its first lookup into the context and is never // recovery eligible, so a stale miss there has no later chance to // retry. Finalization must revalidate before dropping collected IDs. @@ -1637,7 +1680,9 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &ec_id, &response); let (stored, _) = graph @@ -1651,8 +1696,8 @@ mod tests { ); } - #[test] - fn finalize_persists_every_configured_partner_from_client_eids_over_a_trimmed_cookie() { + #[tokio::test] + async fn finalize_persists_every_configured_partner_from_client_eids_over_a_trimmed_cookie() { // Regression test for #1184: the `ts-eids` cookie only carries what // the browser could fit under its size cap, but `/auction` also // hands finalization the full EID set from the request body via @@ -1716,7 +1761,9 @@ mod tests { Some(&eids_cookie), None, &mut response, - ); + &noop_services(), + ) + .await; let (stored, _) = graph .get(&ec_id) @@ -1734,8 +1781,8 @@ mod tests { ); } - #[test] - fn finalize_withholds_eid_kv_writes_when_purpose_four_is_denied() { + #[tokio::test] + async fn finalize_withholds_eid_kv_writes_when_purpose_four_is_denied() { // A GDPR user can grant TCF Purpose 1 (storage/EC) while denying // Purpose 4 (personalized ads). `gate_eids_by_consent` already strips // EIDs from the outbound /auction bid request in that case; the KV @@ -1787,7 +1834,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let (stored, _) = graph .get(&ec_id) @@ -1800,8 +1849,8 @@ mod tests { ); } - #[test] - fn finalize_auction_confirmed_miss_does_not_create_a_row() { + #[tokio::test] + async fn finalize_auction_confirmed_miss_does_not_create_a_row() { // The same path with a genuinely absent row must stay a no-op: a route // without orphan recovery must never mint an identity-graph entry. let settings = create_test_settings(); @@ -1827,7 +1876,9 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &ec_id, &response); assert!( @@ -1836,8 +1887,8 @@ mod tests { ); } - #[test] - fn finalize_transient_missing_row_confirms_present_and_does_not_rotate() { + #[tokio::test] + async fn finalize_transient_missing_row_confirms_present_and_does_not_rotate() { // The origin-overlapped preload transiently read `Missing` on an // eventually-consistent store, but the row actually exists. The // confirming re-read at finalize must adopt the live row instead of @@ -1871,7 +1922,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); assert!( @@ -1880,8 +1933,8 @@ mod tests { ); } - #[test] - fn finalize_two_missing_reads_do_not_rotate_a_row_the_store_still_lists() { + #[tokio::test] + async fn finalize_two_missing_reads_do_not_rotate_a_row_the_store_still_lists() { // Both the origin-overlapped preload and the confirming re-read missed, // but the row is live — the point reads were stale. Two stale reads are // not an absence proof, so the identity graph must be left intact and @@ -1918,7 +1971,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); assert_eq!( @@ -1931,8 +1986,8 @@ mod tests { ); } - #[test] - fn finalize_rotates_when_only_a_longer_key_shares_the_orphan_prefix() { + #[tokio::test] + async fn finalize_rotates_when_only_a_longer_key_shares_the_orphan_prefix() { // `key_exists_confirmed` gates orphan recovery as well as withdrawal. // It matches whole keys, so a neighbouring key that merely starts with // the orphaned ID cannot answer for it. Under the prefix count this @@ -1975,7 +2030,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let replacement = ec_context.ec_value().expect("should rotate the orphan"); assert_ne!( @@ -2076,8 +2133,8 @@ mod tests { } } - #[test] - fn an_orphan_recovery_that_cannot_rotate_leaves_a_failed_snapshot() { + #[tokio::test] + async fn an_orphan_recovery_that_cannot_rotate_leaves_a_failed_snapshot() { // Recovery runs only once the orphan's row is proven absent. Each of // these exits gives up without a replacement, and each must leave the // orphan in place, write no cookie and record a failed snapshot for @@ -2127,7 +2184,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_eq!( ec_context.ec_value(), @@ -2152,8 +2211,8 @@ mod tests { } } - #[test] - fn an_orphan_rotation_applies_the_replacement_modules_response_headers() { + #[tokio::test] + async fn an_orphan_rotation_applies_the_replacement_modules_response_headers() { // A returning visitor runs no generation earlier in the request, so the // headers the module asks for while creating the replacement reach // the response through the rotation alone. @@ -2174,7 +2233,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_eq!( ec_context.ec_value(), @@ -2209,8 +2270,8 @@ mod tests { ); } - #[test] - fn finalize_does_not_rotate_when_the_existence_check_fails() { + #[tokio::test] + async fn finalize_does_not_rotate_when_the_existence_check_fails() { // Absence is unprovable when the list itself errors. Rotation abandons a // year-lived identity, so it must not run on an unproven miss. let settings = create_test_settings(); @@ -2233,13 +2294,15 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_generated_ec_does_not_emit_cookie_for_authoritative_missing_row() { + #[tokio::test] + async fn finalize_generated_ec_does_not_emit_cookie_for_authoritative_missing_row() { let settings = create_test_settings(); let generated_ec = sample_ec_id("genmis"); let mut ec_context = make_context( @@ -2264,7 +2327,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "set-cookie").is_none(), @@ -2272,8 +2337,8 @@ mod tests { ); } - #[test] - fn finalize_denied_without_cookie_is_noop() { + #[tokio::test] + async fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); let mut ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); @@ -2287,7 +2352,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -2299,8 +2366,8 @@ mod tests { ); } - #[test] - fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + #[tokio::test] + async fn finalize_not_permitted_without_withdrawal_keeps_cookie() { // When EC is not permitted (here a fail-closed unknown jurisdiction with // no geo) but the request carries no explicit withdrawal signal, the // response strips EC headers yet must leave an already-issued cookie @@ -2329,7 +2396,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -2371,8 +2440,8 @@ mod tests { ); } - #[test] - fn the_permission_gate_decides_whether_a_generated_ec_cookie_is_written() { + #[tokio::test] + async fn the_permission_gate_decides_whether_a_generated_ec_cookie_is_written() { // The generated identifier has a backing row and a snapshot bound to // it, so the backing-row guard would let the cookie through and the // permission gate is the only thing that can withhold it. @@ -2406,7 +2475,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let wrote_ec_cookie = response .headers() @@ -2421,8 +2492,8 @@ mod tests { } } - #[test] - fn withdrawal_tombstones_the_canonical_row_not_the_cookie_value() { + #[tokio::test] + async fn withdrawal_tombstones_the_canonical_row_not_the_cookie_value() { // The tombstone is the authoritative revocation marker, so it has to // land on the key the live row uses. Written under the raw cookie // value it creates a second row nothing reads, and the revocation @@ -2455,7 +2526,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let (live_row, _) = graph .get(CANONICAL_KV_KEY) @@ -2474,8 +2547,8 @@ mod tests { ); } - #[test] - fn eid_ingestion_keys_by_the_modules_canonical_form() { + #[tokio::test] + async fn eid_ingestion_keys_by_the_modules_canonical_form() { // An ingested EID must join the row the identifier already has. Keyed // by the raw cookie value the upsert finds no row and the partner ID // is dropped. @@ -2500,7 +2573,9 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + &noop_services(), + ) + .await; let (row, _) = graph .get(CANONICAL_KV_KEY) @@ -2526,6 +2601,7 @@ mod tests { #[derive(Debug)] struct EvidenceHeaderModule; + #[async_trait::async_trait(?Send)] impl crate::ec::module::EdgeCookieModule for EvidenceHeaderModule { fn id(&self) -> &'static str { "evidence-header" @@ -2535,10 +2611,11 @@ mod tests { crate::module_code!("t0eh") } - fn generate( + async fn generate( &self, _request_info: &dyn crate::evidence::RequestInfo, _input: &crate::ec::module::IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result< crate::ec::module::GeneratedEdgeCookie, error_stack::Report, @@ -2564,8 +2641,8 @@ mod tests { } } - #[test] - fn module_response_headers_reach_the_response_without_dropping_the_origins() { + #[tokio::test] + async fn module_response_headers_reach_the_response_without_dropping_the_origins() { // The response finalization runs on is the finished one, so it already // carries the publisher origin's own headers. A module effect must // add to those, never replace them: replacing `Set-Cookie` would drop @@ -2581,7 +2658,8 @@ mod tests { let mut ec_context = make_context_with_consent(None, None, false, false, consent, true) .with_module_for_test(std::sync::Arc::new(EvidenceHeaderModule)); ec_context - .generate_if_needed(&settings, Some(&graph)) + .generate_if_needed(&settings, Some(&graph), &noop_services()) + .await .expect("should create the identifier through the module"); // What the publisher's origin returned, before EC finalization runs. @@ -2603,7 +2681,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let cookies: Vec<&str> = response .headers() @@ -2649,6 +2729,7 @@ mod tests { #[derive(Debug)] struct SwitchedModule; + #[async_trait::async_trait(?Send)] impl crate::ec::module::EdgeCookieModule for SwitchedModule { fn id(&self) -> &'static str { "switched" @@ -2658,10 +2739,11 @@ mod tests { crate::module_code!("t0sw") } - fn generate( + async fn generate( &self, _request_info: &dyn crate::evidence::RequestInfo, _input: &crate::ec::module::IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result< crate::ec::module::GeneratedEdgeCookie, error_stack::Report, @@ -2678,8 +2760,8 @@ mod tests { } } - #[test] - fn switching_module_leaves_the_previous_modules_row_beyond_withdrawal() { + #[tokio::test] + async fn switching_module_leaves_the_previous_modules_row_beyond_withdrawal() { // A retired module's identifier is owned by nobody this deployment // reads, so a later withdrawal expires the browser cookie but cannot // tombstone the row, and the identifier is never adopted either. @@ -2736,7 +2818,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( ec_context.ec_value().is_none(), @@ -2765,8 +2849,8 @@ mod tests { "withdrawal should still expire the browser cookie after a switch, got {cookies:?}" ); } - #[test] - fn marker_is_expired_when_the_selected_module_does_not_own_the_cookie() { + #[tokio::test] + async fn marker_is_expired_when_the_selected_module_does_not_own_the_cookie() { // A switch between client-cycle modules looks like this on the first // request after the switch. The raw cookie is still presented, but the // selected module does not recognize it, so no EC is in play. @@ -2789,7 +2873,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let expired = response .headers() @@ -2803,8 +2889,8 @@ mod tests { ); } - #[test] - fn marker_is_left_alone_when_the_module_owns_the_cookie() { + #[tokio::test] + async fn marker_is_left_alone_when_the_module_owns_the_cookie() { let settings = create_test_settings(); let mut ec_context = make_context( Some("an-ec"), @@ -2824,7 +2910,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let expired = response .headers() @@ -2879,8 +2967,8 @@ mod tests { ); } - #[test] - fn finalize_not_read_snapshot_does_not_rotate() { + #[tokio::test] + async fn finalize_not_read_snapshot_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("notrd1"); let mut ec_context = returning_user_context(&orphan, EcKvSnapshot::NotRead, true); @@ -2895,13 +2983,15 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_failed_snapshot_does_not_rotate() { + #[tokio::test] + async fn finalize_failed_snapshot_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("faild1"); let mut ec_context = returning_user_context( @@ -2922,13 +3012,15 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_tombstone_snapshot_does_not_rotate() { + #[tokio::test] + async fn finalize_tombstone_snapshot_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("tomb01"); let tombstone = EcKvSnapshot::Present { @@ -2948,13 +3040,15 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_subresource_missing_row_does_not_rotate() { + #[tokio::test] + async fn finalize_subresource_missing_row_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("subrs1"); // Missing row, but the request is not a recovery-eligible browser navigation. @@ -2976,7 +3070,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); assert!( @@ -2985,8 +3081,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_tombstones_present_id_and_skips_missing_other() { + #[tokio::test] + async fn finalize_withdrawal_tombstones_present_id_and_skips_missing_other() { let settings = create_test_settings(); let active_ec = sample_ec_id("activ2"); let cookie_ec = sample_ec_id("cook2e"); @@ -3025,7 +3121,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let (active_stored, _) = graph .get(&active_ec) @@ -3041,8 +3139,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_tombstones_both_present_ids_once() { + #[tokio::test] + async fn finalize_withdrawal_tombstones_both_present_ids_once() { let settings = create_test_settings(); let active_ec = sample_ec_id("activ3"); let cookie_ec = sample_ec_id("cook3e"); @@ -3085,7 +3183,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let (active_tombstone, active_generation) = graph .get(&active_ec) @@ -3121,7 +3221,9 @@ mod tests { None, None, &mut repeated_response, - ); + &noop_services(), + ) + .await; assert_eq!( graph @@ -3143,8 +3245,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_keeps_cookie_deletion_on_kv_failure() { + #[tokio::test] + async fn finalize_withdrawal_keeps_cookie_deletion_on_kv_failure() { let settings = create_test_settings(); let ec_id = sample_ec_id("failw1"); let consent = ConsentContext { @@ -3170,7 +3272,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let cookies = response .headers() @@ -3197,8 +3301,8 @@ mod tests { ); } - #[test] - fn finalize_sets_marker_for_complete_pull_partner_snapshot() { + #[tokio::test] + async fn finalize_sets_marker_for_complete_pull_partner_snapshot() { let settings = create_test_settings(); let ec_id = sample_ec_id("compl1"); let mut partner = make_partner("ssp.example.com"); @@ -3237,7 +3341,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let cookies = response .headers() @@ -3253,8 +3359,8 @@ mod tests { ); } - #[test] - fn explicit_withdrawal_without_marker_or_ec_cookie_does_not_set_cookie() { + #[tokio::test] + async fn explicit_withdrawal_without_marker_or_ec_cookie_does_not_set_cookie() { let settings = create_test_settings(); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), @@ -3273,7 +3379,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert!( response.headers().get(http::header::SET_COOKIE).is_none(), @@ -3281,8 +3389,8 @@ mod tests { ); } - #[test] - fn explicit_withdrawal_expires_marker_without_ec_cookie() { + #[tokio::test] + async fn explicit_withdrawal_expires_marker_without_ec_cookie() { let settings = create_test_settings(); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), @@ -3305,7 +3413,9 @@ mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let cookies = response .headers() diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index acafcdd31b..823b6f6285 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -369,12 +369,15 @@ impl EcContext { /// Returns [`TrustedServerError`] when the selected Edge Cookie module /// cannot be built, the same as /// [`read_from_request_with_geo`](Self::read_from_request_with_geo). - pub fn read_from_request_resolving_geo( + pub async fn read_from_request_resolving_geo( settings: &Settings, req: &Request, services: &RuntimeServices, ) -> Result> { - let lookup = services.geo().lookup(services.client_info().client_ip); + let lookup = services + .geo() + .lookup(services.client_info().client_ip, services) + .await; let geo_info = match &lookup { Ok(info) => info.clone(), Err(error) => { @@ -558,10 +561,11 @@ impl EcContext { /// reserved surface, persisting the identifier to the KV identity graph /// failing, or every attempt within the retry limit producing an identifier /// the graph already holds. - pub fn generate_if_needed( + pub async fn generate_if_needed( &mut self, settings: &Settings, kv: Option<&KvIdentityGraph>, + services: &RuntimeServices, ) -> Result<(), Report> { if self.ec_value.is_some() { return Ok(()); @@ -601,7 +605,8 @@ impl EcContext { // and a module that needs it refuses there, returning the error to // the caller. The publisher proxy and integration proxy log it and // serve the response without an Edge Cookie. - self.generate_with_module(ec_module.as_ref(), settings, kv) + self.generate_with_module(ec_module.as_ref(), settings, kv, services) + .await } /// Asks the selected module for one new identifier, without persisting @@ -627,9 +632,10 @@ impl EcContext { /// [`reserved_response_effect`](crate::ec::module::reserved_response_effect)), /// or produces an identifier that is empty, over the length cap, or outside /// the cookie-safe alphabet. - pub(crate) fn candidate_id( + pub(crate) async fn candidate_id( &mut self, ec_module: &dyn EdgeCookieModule, + services: &RuntimeServices, ) -> Result, Report> { let input = IdentityInput { permissions: Some(&self.permissions), @@ -640,7 +646,8 @@ impl EcContext { Some(&self.request_headers), ) .with_request_target(&self.request_path, &self.request_query); - let generated: GeneratedEdgeCookie = ec_module.generate(&request_info, &input)?; + let generated: GeneratedEdgeCookie = + ec_module.generate(&request_info, &input, services).await?; // Check every response header the module asked for against core's // reserved surface before any of them are kept. A module may set its // own cookies and headers, but not a managed `ts-` cookie, a header in @@ -729,15 +736,16 @@ impl EcContext { /// returns [`TrustedServerError::EdgeCookie`] when persisting a generated /// identifier to the KV identity graph fails or every attempt within the /// retry limit produces an identifier the graph already holds. - fn generate_with_module( + async fn generate_with_module( &mut self, ec_module: &dyn EdgeCookieModule, settings: &Settings, kv: Option<&KvIdentityGraph>, + services: &RuntimeServices, ) -> Result<(), Report> { const MAX_CREATE_ATTEMPTS: usize = 5; for attempt in 0..MAX_CREATE_ATTEMPTS { - let Some(ec_id) = self.candidate_id(ec_module)? else { + let Some(ec_id) = self.candidate_id(ec_module, services).await? else { return Ok(()); }; // Key the identity graph by the module's canonical form of the @@ -1416,15 +1424,16 @@ pub(crate) mod tests { } } - #[test] - fn generate_if_needed_retries_id_collision_then_persists() { + #[tokio::test] + async fn generate_if_needed_retries_id_collision_then_persists() { let settings = create_test_settings(); let mut ec = EcContext::new_for_test_with_ip(None, granting_consent(), Some("192.0.2.5".to_owned())) .with_module_for_test(hmac_module()); let graph = KvIdentityGraph::new(AddCollidingEcKv::new(2)); - ec.generate_if_needed(&settings, Some(&graph)) + ec.generate_if_needed(&settings, Some(&graph), &noop_services()) + .await .expect("should generate after bounded collisions"); assert!(ec.ec_value().is_some(), "should allocate a fresh EC ID"); @@ -1435,8 +1444,8 @@ pub(crate) mod tests { ); } - #[test] - fn generate_if_needed_errors_after_collision_exhaustion() { + #[tokio::test] + async fn generate_if_needed_errors_after_collision_exhaustion() { let settings = create_test_settings(); let mut ec = EcContext::new_for_test_with_ip(None, granting_consent(), Some("192.0.2.6".to_owned())) @@ -1444,7 +1453,9 @@ pub(crate) mod tests { // Collide on every attempt so the bounded retry is exhausted. let graph = KvIdentityGraph::new(AddCollidingEcKv::new(u32::MAX)); - let result = ec.generate_if_needed(&settings, Some(&graph)); + let result = ec + .generate_if_needed(&settings, Some(&graph), &noop_services()) + .await; assert!(result.is_err(), "should fail after exhausting attempts"); assert!( @@ -1513,6 +1524,7 @@ pub(crate) mod tests { #[derive(Debug)] pub(crate) struct OpaqueModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for OpaqueModule { fn id(&self) -> &'static str { "opaque" @@ -1522,10 +1534,11 @@ pub(crate) mod tests { crate::module_code!("t0op") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1656,6 +1669,7 @@ pub(crate) mod tests { seen_client_ip: std::sync::Mutex>, } + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for EvidenceCapturingModule { fn id(&self) -> &'static str { "evidence" @@ -1665,10 +1679,11 @@ pub(crate) mod tests { crate::module_code!("t0ev") } - fn generate( + async fn generate( &self, request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { let query_id = request_info.query_param("id").unwrap_or_default(); let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); @@ -1689,8 +1704,8 @@ pub(crate) mod tests { } } - #[test] - fn generate_passes_request_parameters_and_cookies_to_the_module() { + #[tokio::test] + async fn generate_passes_request_parameters_and_cookies_to_the_module() { use crate::platform::test_support::noop_services_with_ec_module; let module = Arc::new(EvidenceCapturingModule::default()); @@ -1710,7 +1725,8 @@ pub(crate) mod tests { let geo = non_regulated_geo(); let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) .expect("should read EC context"); - ec.generate_if_needed(&settings, None) + ec.generate_if_needed(&settings, None, &services) + .await .expect("should run generation"); let seen = module @@ -1730,8 +1746,8 @@ pub(crate) mod tests { ); } - #[test] - fn recovery_on_a_navigation_passes_request_parameters_and_cookies_to_the_module() { + #[tokio::test] + async fn recovery_on_a_navigation_passes_request_parameters_and_cookies_to_the_module() { use crate::platform::test_support::noop_services_with_ec_module; let module = Arc::new(EvidenceCapturingModule::default()); @@ -1755,7 +1771,8 @@ pub(crate) mod tests { .expect("should read EC context"); assert!(ec.ec_was_present(), "the identifier should be read back"); let selected = ec.selected_module().expect("a module is selected"); - ec.candidate_id(selected.as_ref()) + ec.candidate_id(selected.as_ref(), &services) + .await .expect("should create a replacement"); let seen = module @@ -1776,6 +1793,7 @@ pub(crate) mod tests { #[derive(Debug)] struct ServerOpaqueModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for ServerOpaqueModule { fn id(&self) -> &'static str { "server_opaque" @@ -1785,10 +1803,11 @@ pub(crate) mod tests { crate::module_code!("t0so") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), @@ -1805,8 +1824,8 @@ pub(crate) mod tests { } } - #[test] - fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { + #[tokio::test] + async fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { use crate::platform::test_support::noop_services_with_ec_module; const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; @@ -1820,7 +1839,8 @@ pub(crate) mod tests { let req = create_test_request(&[]); let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); - ec.generate_if_needed(&settings, Some(&graph)) + ec.generate_if_needed(&settings, Some(&graph), &services) + .await .expect("should generate and persist"); assert_eq!( @@ -1846,8 +1866,152 @@ pub(crate) mod tests { ); } - #[test] - fn a_module_that_reads_no_client_ip_creates_when_the_host_has_none() { + /// A geo module that resolves the country from the config store it is + /// handed, the geo counterpart of [`ConfigReadingModule`]. + #[derive(Debug)] + struct ConfigReadingGeo; + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformGeo for ConfigReadingGeo { + async fn lookup( + &self, + _client_ip: Option, + services: &crate::platform::RuntimeServices, + ) -> Result, Report> { + let country = services + .config_store() + .get(&crate::platform::StoreName::from("vendor_store"), "country")?; + Ok(Some(GeoInfo { + country, + ..non_regulated_geo() + })) + } + } + + #[tokio::test] + async fn a_geo_module_reads_a_platform_service_through_the_services_it_is_given() { + use crate::platform::test_support::FixedConfigStore; + + // The geo half of the seam acceptance test. Geo runs ahead of the + // permission model and feeds it the country, so a vendor geo module + // that resolves location from a backend or a store needs the platform + // services exactly as an Edge Cookie module does. `DE` can only + // appear here if the module read it through the services this call + // supplied. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let services = crate::platform::test_support::test_services_builder() + .config_store(Arc::new(FixedConfigStore { + store: "vendor_store", + key: "country", + value: "DE", + })) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(crate::platform::test_support::NoopBackend)) + .http_client(Arc::new(crate::platform::test_support::NoopHttpClient)) + .geo(Arc::new(ConfigReadingGeo)) + .client_info(crate::platform::ClientInfo::default()) + .build(); + + let ec = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .await + .expect("should read EC context"); + + assert_eq!( + ec.geo_info().map(|info| info.country.as_str()), + Some("DE"), + "the resolved country must come from the value the geo module read through the services it was handed" + ); + } + + /// A module that derives its identifier from a value it reads out of the + /// config store at generate time, which is the whole point of handing modules + /// the platform services. Nothing about the value is known when the + /// module is constructed, so an identifier carrying it can only come from + /// a real read through the services passed to `generate`. + #[derive(Debug)] + struct ConfigReadingModule; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieModule for ConfigReadingModule { + fn id(&self) -> &'static str { + "config-reading" + } + + fn code(&self) -> ModuleCode { + crate::module_code!("t0cr") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + services: &crate::platform::RuntimeServices, + ) -> Result> { + let tenant = services + .config_store() + .get(&crate::platform::StoreName::from("vendor_store"), "tenant") + .map_err(|error| { + error.change_context(TrustedServerError::EdgeCookie { + message: "config-reading module could not read its tenant".to_owned(), + }) + })?; + Ok(GeneratedEdgeCookie { + id: Some(format!("tenant-{tenant}")), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[tokio::test] + async fn a_module_reads_a_platform_service_through_the_services_it_is_given() { + use crate::platform::test_support::{ + FixedConfigStore, services_with_ec_module_and_config_store, + }; + + // The acceptance test for the asynchronous module seam. Before + // modules were handed the platform services, a module could not + // reach a config store, a key-value store, a secret or a backend at + // all, which made every real vendor module impossible to write. This + // proves the services that arrive at `generate` are the caller's real + // ones: the identifier can only carry `acme` if the module actually + // read it out of the store on this request, because nothing gives the + // module that value at construction time. + let module = Arc::new(ConfigReadingModule); + let mut settings = create_test_settings(); + settings.ec.module = Some(EcModuleSelection::from("config-reading")); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + + let services = services_with_ec_module_and_config_store( + module.clone(), + Arc::new(FixedConfigStore { + store: "vendor_store", + key: "tenant", + value: "acme", + }), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + + ec.generate_if_needed(&settings, None, &services) + .await + .expect("the module should create an identifier from the config value it read"); + + assert_eq!( + ec.ec_value(), + Some("t0cr~tenant-acme"), + "the identifier must carry the value the module read through the services it was handed, which proves the seam delivers them" + ); + } + + #[tokio::test] + async fn a_module_that_reads_no_client_ip_creates_when_the_host_has_none() { use crate::platform::test_support::noop_services_with_ec_module_without_client_ip; // The requirement for a client IP belongs to the module that uses @@ -1876,7 +2040,8 @@ pub(crate) mod tests { "the host should supply no client IP in this test" ); - ec.generate_if_needed(&settings, None) + ec.generate_if_needed(&settings, None, &services) + .await .expect("a module that reads no client IP should still create an identifier"); assert_eq!( ec.ec_value(), @@ -1894,8 +2059,8 @@ pub(crate) mod tests { ); } - #[test] - fn the_hmac_module_refuses_when_the_host_has_no_client_ip() { + #[tokio::test] + async fn the_hmac_module_refuses_when_the_host_has_no_client_ip() { // The other half: the built-in module's only input is the client IP, // so with none it fails rather than hashing the empty string into an // identifier every visitor on that host would share. Identity cannot be @@ -1915,7 +2080,8 @@ pub(crate) mod tests { ); let err = ec - .generate_if_needed(&settings, None) + .generate_if_needed(&settings, None, &noop_services()) + .await .expect_err("the HMAC module should refuse without a client IP"); assert!( err.to_string().contains("client IP"), @@ -1933,6 +2099,7 @@ pub(crate) mod tests { #[derive(Debug)] struct IllegalIdModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for IllegalIdModule { fn id(&self) -> &'static str { "illegal" @@ -1942,10 +2109,11 @@ pub(crate) mod tests { crate::module_code!("t0il") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("bad;value with spaces".to_owned()), @@ -1958,8 +2126,8 @@ pub(crate) mod tests { } } - #[test] - fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { + #[tokio::test] + async fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { use crate::platform::test_support::noop_services_with_ec_module; let mut settings = create_test_settings(); @@ -1970,7 +2138,8 @@ pub(crate) mod tests { .expect("should read EC context"); let err = ec - .generate_if_needed(&settings, None) + .generate_if_needed(&settings, None, &services) + .await .expect_err("an identifier outside the alphabet should be rejected at creation"); assert!( err.to_string().contains("illegal"), @@ -1993,6 +2162,7 @@ pub(crate) mod tests { id: Option<&'static str>, } + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for HeaderSettingModule { fn id(&self) -> &'static str { "header_setting" @@ -2002,10 +2172,11 @@ pub(crate) mod tests { crate::module_code!("t0hs") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie { id: self.id.map(str::to_owned), @@ -2029,7 +2200,7 @@ pub(crate) mod tests { /// Runs generation through `module` and hands back the context whether or /// not generation succeeded, so a test can finalize a response on a context /// whose generation returned an error. - fn generate_with_header_setting_module( + async fn generate_with_header_setting_module( module: HeaderSettingModule, graph: Option<&KvIdentityGraph>, ) -> (Settings, EcContext, Result<(), Report>) { @@ -2041,12 +2212,12 @@ pub(crate) mod tests { let req = create_test_request(&[]); let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); - let outcome = ec.generate_if_needed(&settings, graph); + let outcome = ec.generate_if_needed(&settings, graph, &services).await; (settings, ec, outcome) } - #[test] - fn a_rejected_module_effect_never_reaches_the_finalized_response() { + #[tokio::test] + async fn a_rejected_module_effect_never_reaches_the_finalized_response() { // A module that sets the managed `ts-ec` cookie, a header in the // `x-ts-` namespace or a framing header would bypass core's identifier // validation and its identity-graph row, so generation returns an @@ -2074,7 +2245,8 @@ pub(crate) mod tests { let (settings, mut ec, outcome) = generate_with_header_setting_module( HeaderSettingModule { name, value, id }, Some(&graph), - ); + ) + .await; let Err(err) = outcome else { panic!("{case}: the header is reserved, so generation should return an error"); }; @@ -2100,7 +2272,9 @@ pub(crate) mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let cookies: Vec<&str> = response .headers() @@ -2126,8 +2300,8 @@ pub(crate) mod tests { } } - #[test] - fn generate_applies_a_module_owned_cookie_to_the_response() { + #[tokio::test] + async fn generate_applies_a_module_owned_cookie_to_the_response() { // The other half of the rule: a module's own cookie is not core's, so // it survives generation and reaches the browser response unchanged, // alongside the managed `ts-ec` cookie core writes itself. @@ -2139,7 +2313,8 @@ pub(crate) mod tests { id: Some("module-value"), }, Some(&graph), - ); + ) + .await; outcome.expect("generation should accept a module-owned cookie"); assert_eq!( ec.ec_value(), @@ -2159,7 +2334,9 @@ pub(crate) mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; let cookies: Vec<&str> = response .headers() @@ -2185,7 +2362,7 @@ pub(crate) mod tests { /// Runs EC finalization on `ec` for an empty response and returns the /// `Set-Cookie` values the response carries. - fn finalized_set_cookies( + async fn finalized_set_cookies( settings: &Settings, ec: &mut EcContext, graph: &KvIdentityGraph, @@ -2202,7 +2379,9 @@ pub(crate) mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; response .headers() .get_all(http::header::SET_COOKIE) @@ -2212,8 +2391,8 @@ pub(crate) mod tests { .collect() } - #[test] - fn a_discarded_candidate_keeps_none_of_its_module_response_headers() { + #[tokio::test] + async fn a_discarded_candidate_keeps_none_of_its_module_response_headers() { // The module's own cookie is permitted, but each of these candidates // is discarded, so generation returns an error and the cookie its // module response asked for must not reach the browser. @@ -2247,13 +2426,14 @@ pub(crate) mod tests { id: Some(id), }, Some(&graph), - ); + ) + .await; assert!( outcome.is_err(), "{case}: generation should return an error" ); - let cookies = finalized_set_cookies(&settings, &mut ec, &graph); + let cookies = finalized_set_cookies(&settings, &mut ec, &graph).await; assert!( !cookies .iter() @@ -2264,8 +2444,8 @@ pub(crate) mod tests { } } - #[test] - fn a_module_that_creates_nothing_still_has_its_headers_applied() { + #[tokio::test] + async fn a_module_that_creates_nothing_still_has_its_headers_applied() { // A module may ask for more client evidence before it can create an // identifier, for example with `Accept-CH`, so its headers reach the // response even though no Edge Cookie is set. @@ -2277,7 +2457,8 @@ pub(crate) mod tests { id: None, }, Some(&graph), - ); + ) + .await; outcome.expect("a module that creates nothing should not fail the request"); assert_eq!(ec.ec_value(), None, "the module created no identifier"); @@ -2293,7 +2474,9 @@ pub(crate) mod tests { None, None, &mut response, - ); + &noop_services(), + ) + .await; assert_eq!( response @@ -2323,6 +2506,7 @@ pub(crate) mod tests { #[derive(Debug)] pub(crate) struct CanonicalizingModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for CanonicalizingModule { fn id(&self) -> &'static str { "canonical" @@ -2332,10 +2516,11 @@ pub(crate) mod tests { crate::module_code!("t0ca") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("MiXeD.CaseId".to_owned()), @@ -2374,8 +2559,8 @@ pub(crate) mod tests { )) } - #[test] - fn generate_keys_the_identity_graph_by_the_normalized_identifier() { + #[tokio::test] + async fn generate_keys_the_identity_graph_by_the_normalized_identifier() { use crate::platform::test_support::noop_services_with_ec_module; let mut settings = create_test_settings(); @@ -2385,7 +2570,8 @@ pub(crate) mod tests { let req = create_test_request(&[]); let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); - ec.generate_if_needed(&settings, Some(&graph)) + ec.generate_if_needed(&settings, Some(&graph), &services) + .await .expect("should generate and persist"); assert_eq!( @@ -2410,8 +2596,8 @@ pub(crate) mod tests { ); } - #[test] - fn hmac_creates_a_coded_identifier_and_reads_the_legacy_bare_form() { + #[tokio::test] + async fn hmac_creates_a_coded_identifier_and_reads_the_legacy_bare_form() { let settings = create_test_settings(); // Place the request in a US opt-out state, whose baseline grants the // storage permission with no signal, so the creation runs. @@ -2422,7 +2608,8 @@ pub(crate) mod tests { ); let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) .expect("should read EC context"); - ec.generate_if_needed(&settings, None) + ec.generate_if_needed(&settings, None, &services) + .await .expect("should generate"); let created = ec.ec_value().expect("should create an identifier"); assert!( @@ -2445,8 +2632,8 @@ pub(crate) mod tests { ); } - #[test] - fn the_request_path_hashes_an_ipv6_client_ip_by_its_64_prefix() { + #[tokio::test] + async fn the_request_path_hashes_an_ipv6_client_ip_by_its_64_prefix() { // A device rotates the lower 64 bits of its IPv6 address, so the // context keeps only the /64 prefix and the HMAC module hashes that. // Two addresses in one /64 share an identity hash, and it is the hash @@ -2474,7 +2661,8 @@ pub(crate) mod tests { Some(prefix), "{ip}: the context should keep the /64 prefix alone" ); - ec.generate_if_needed(&settings, None) + ec.generate_if_needed(&settings, None, &services) + .await .expect("should generate"); let created = ec .ec_value() @@ -2518,10 +2706,12 @@ pub(crate) mod tests { #[derive(Debug)] struct FailingGeo; + #[async_trait::async_trait(?Send)] impl crate::platform::PlatformGeo for FailingGeo { - fn lookup( + async fn lookup( &self, _client_ip: Option, + _services: &crate::platform::RuntimeServices, ) -> Result, Report> { Err(Report::new(crate::platform::PlatformError::Geo)) } @@ -2542,8 +2732,8 @@ pub(crate) mod tests { } } - #[test] - fn a_geo_module_failure_resolves_permissions_at_the_requires_signal_floor() { + #[tokio::test] + async fn a_geo_module_failure_resolves_permissions_at_the_requires_signal_floor() { use crate::permissions::Permission; use crate::platform::test_support::build_services_with_geo; @@ -2563,6 +2753,7 @@ pub(crate) mod tests { let services = build_services_with_geo(std::sync::Arc::new(FailingGeo)); let failed = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .await .expect("a failed lookup should resolve permissions, not fail the request"); assert!( !failed.permissions().is_set(Permission::StoreOnDevice), @@ -2767,8 +2958,8 @@ pub(crate) mod tests { ); } - #[test] - fn generate_if_needed_skips_when_ec_exists() { + #[tokio::test] + async fn generate_if_needed_skips_when_ec_exists() { let settings = create_test_settings(); let ec_id = valid_ec_id("d", "Exist1"); let cookie = format!("ts-ec={ec_id}"); @@ -2776,7 +2967,8 @@ pub(crate) mod tests { let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) .expect("should read EC context"); - ec.generate_if_needed(&settings, None) + ec.generate_if_needed(&settings, None, &noop_services()) + .await .expect("should not error when EC already exists"); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index 87bcf898c6..5cfb6afdd3 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -617,6 +617,12 @@ impl<'a> AcceptedModules<'a> { /// A module that cannot derive an identifier at the edge returns a /// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so /// the request proceeds without an Edge Cookie rather than failing. +/// Uses `#[async_trait(?Send)]` for the same reason as +/// [`PlatformHttpClient`](crate::platform::PlatformHttpClient): the trait +/// object stays `Send + Sync` so it can be shared and run multi-threaded, +/// while the future it returns is pinned to one thread because the host SDKs +/// produce `!Send` futures on wasm32. +#[async_trait::async_trait(?Send)] pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { /// Returns the stable implementation id for this module, used in /// configuration and logs. @@ -664,10 +670,16 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. - fn generate( + /// Asynchronous, and handed the platform services, because a module may + /// reach a backend, a key-value store or a secret to derive an identifier, + /// and a module that cannot make those calls cannot be written at all. + /// A module that derives from data already in hand still declares an + /// async method and returns immediately. + async fn generate( &self, request_info: &dyn RequestInfo, input: &IdentityInput<'_>, + services: &crate::platform::RuntimeServices, ) -> Result>; /// Returns whether `value` is a well-formed identifier this module issues. @@ -740,9 +752,14 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { /// Returns [`TrustedServerError::EdgeCookie`] when processing the payload /// fails. A payload that is merely unverified or absent yields `id: None` /// rather than an error, so the request proceeds without an Edge Cookie. - fn resolve_from_client( + /// Asynchronous, and handed the platform services, for the same reason as + /// [`generate`](Self::generate). Verifying a payload the browser posted is + /// the case that most needs them, because checking a signature or a nonce + /// generally means reading a secret or calling the vendor's backend. + async fn resolve_from_client( &self, _input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -774,6 +791,7 @@ impl HmacModule { } } +#[async_trait::async_trait(?Send)] impl EdgeCookieModule for HmacModule { fn id(&self) -> &'static str { HMAC_MODULE_KEY @@ -783,10 +801,11 @@ impl EdgeCookieModule for HmacModule { HMAC_MODULE_CODE } - fn generate( + async fn generate( &self, request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { let client_ip = request_info.client_ip(); if client_ip.is_empty() { @@ -835,6 +854,7 @@ impl HostSignalModule { } } +#[async_trait::async_trait(?Send)] impl EdgeCookieModule for HostSignalModule { fn id(&self) -> &'static str { HOST_SIGNALS_MODULE_KEY @@ -850,10 +870,11 @@ impl EdgeCookieModule for HostSignalModule { crate::module_code!("hs00") } - fn generate( + async fn generate( &self, request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { let ja4 = self.host_signals.ja4().unwrap_or_default(); let h2 = self.host_signals.h2().unwrap_or_default(); @@ -914,6 +935,7 @@ const EXPECTED_VALUE: &str = "an-ec"; pub struct ClientFixedModule; #[cfg(any(test, feature = "client-fixed-demo"))] +#[async_trait::async_trait(?Send)] impl EdgeCookieModule for ClientFixedModule { fn id(&self) -> &'static str { CLIENT_FIXED_MODULE_KEY @@ -923,19 +945,21 @@ impl EdgeCookieModule for ClientFixedModule { crate::module_code!("cfix") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { // No identifier is derived at the edge: the value comes from the page // script, which posts it to the resolve endpoint. Ok(GeneratedEdgeCookie::default()) } - fn resolve_from_client( + async fn resolve_from_client( &self, input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { // Verify the posted value against the known shared word, then create it as // the Edge Cookie. A value that does not match yields no Edge Cookie. @@ -1350,6 +1374,7 @@ pub fn request_module( #[derive(Debug)] struct SharedModule(Arc); +#[async_trait::async_trait(?Send)] impl EdgeCookieModule for SharedModule { fn code(&self) -> ModuleCode { self.0.code() @@ -1363,12 +1388,13 @@ impl EdgeCookieModule for SharedModule { self.0.is_request_scoped() } - fn generate( + async fn generate( &self, request_info: &dyn RequestInfo, input: &IdentityInput<'_>, + services: &crate::platform::RuntimeServices, ) -> Result> { - self.0.generate(request_info, input) + self.0.generate(request_info, input, services).await } fn accepts_id(&self, value: &str) -> bool { @@ -1383,11 +1409,12 @@ impl EdgeCookieModule for SharedModule { self.0.required_permissions() } - fn resolve_from_client( + async fn resolve_from_client( &self, input: &ClientResolveInput<'_>, + services: &crate::platform::RuntimeServices, ) -> Result> { - self.0.resolve_from_client(input) + self.0.resolve_from_client(input, services).await } } @@ -1395,6 +1422,7 @@ impl EdgeCookieModule for SharedModule { mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::platform::test_support::noop_services; use crate::test_support::tests::{select_hmac_module, select_host_signals_module}; use http::HeaderMap; @@ -1579,6 +1607,7 @@ mod tests { #[derive(Debug)] struct VendorModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for VendorModule { fn id(&self) -> &'static str { "acme" @@ -1588,10 +1617,11 @@ mod tests { crate::module_code!("t0ac") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1871,6 +1901,7 @@ mod tests { #[derive(Debug)] struct Inner; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for Inner { fn id(&self) -> &'static str { "inner" @@ -1880,10 +1911,11 @@ mod tests { crate::module_code!("t0in") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1926,6 +1958,7 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for ReissuedEnvelopeModule { fn id(&self) -> &'static str { "reissued_envelope" @@ -1935,10 +1968,11 @@ mod tests { crate::module_code!("t0re") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1991,6 +2025,7 @@ mod tests { #[derive(Debug)] struct VendorNamedHmacModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for VendorNamedHmacModule { fn id(&self) -> &'static str { HMAC_MODULE_KEY @@ -2000,10 +2035,11 @@ mod tests { crate::module_code!("t0vh") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2070,8 +2106,8 @@ mod tests { ); } - #[test] - fn host_signal_module_mints_from_fingerprints_and_requires_store_on_device() { + #[tokio::test] + async fn host_signal_module_mints_from_fingerprints_and_requires_store_on_device() { let signals = Arc::new(TestHostSignals { ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), h2: Some("1:65536;4:6291456".to_owned()), @@ -2079,7 +2115,8 @@ mod tests { let module = HostSignalModule::new(test_passphrase(), signals); let request_info = test_request_info(); let generated = module - .generate(&request_info, &IdentityInput::default()) + .generate(&request_info, &IdentityInput::default(), &noop_services()) + .await .expect("should generate"); assert!( generated.id.is_some(), @@ -2098,6 +2135,7 @@ mod tests { #[derive(Debug)] struct MinimalModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for MinimalModule { fn id(&self) -> &'static str { "minimal" @@ -2107,10 +2145,11 @@ mod tests { crate::module_code!("t0mi") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2152,11 +2191,12 @@ mod tests { ); } - #[test] - fn client_fixed_defers_in_generate() { + #[tokio::test] + async fn client_fixed_defers_in_generate() { let request_info = test_request_info(); let generated = ClientFixedModule - .generate(&request_info, &IdentityInput::default()) + .generate(&request_info, &IdentityInput::default(), &noop_services()) + .await .expect("should generate"); assert!( generated.id.is_none(), @@ -2164,8 +2204,8 @@ mod tests { ); } - #[test] - fn resolve_from_client_creates_only_from_a_verified_payload() { + #[tokio::test] + async fn resolve_from_client_creates_only_from_a_verified_payload() { // `client_fixed` creates the Edge Cookie only from the known shared // word. `HmacModule` does not override `resolve_from_client`, so it // inherits the no-op default, because a server-side module takes no @@ -2197,7 +2237,8 @@ mod tests { consent: None, }; let generated = module - .resolve_from_client(&input) + .resolve_from_client(&input, &noop_services()) + .await .unwrap_or_else(|err| panic!("{case}: should resolve, got: {err}")); assert_eq!( generated.id.as_deref(), @@ -2272,8 +2313,8 @@ mod tests { ); } - #[test] - fn host_signal_module_defers_without_fingerprints() { + #[tokio::test] + async fn host_signal_module_defers_without_fingerprints() { let signals = Arc::new(TestHostSignals { ja4: None, h2: None, @@ -2281,7 +2322,8 @@ mod tests { let module = HostSignalModule::new(test_passphrase(), signals); let request_info = test_request_info(); let generated = module - .generate(&request_info, &IdentityInput::default()) + .generate(&request_info, &IdentityInput::default(), &noop_services()) + .await .expect("should generate"); assert!( generated.id.is_none(), diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs index dac25c393d..faeb16d829 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -69,11 +69,12 @@ const MAX_BODY_SIZE: usize = 64 * 1024; /// [`reserved_response_effect`](crate::ec::module::reserved_response_effect)). /// A payload that is merely unverified or absent yields a `204` rather than an /// error. -pub fn handle_ec_resolve( +pub async fn handle_ec_resolve( settings: &Settings, req: Request, ec_context: &EcContext, kv: Option<&KvIdentityGraph>, + services: &crate::platform::RuntimeServices, ) -> Result, Report> { // This endpoint sets identity state from a page script, so the posted // request must originate from the publisher's own site. Browsers always @@ -121,7 +122,7 @@ pub fn handle_ec_resolve( consent: Some(ec_context.consent()), }; - let generated = module.resolve_from_client(&input)?; + let generated = module.resolve_from_client(&input, services).await?; log::debug!( "EC resolve handled (module={}): id {}", module.id(), @@ -506,6 +507,7 @@ mod tests { #[derive(Debug)] struct TestIdModule; + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for TestIdModule { fn id(&self) -> &'static str { "testid" @@ -515,18 +517,20 @@ mod tests { crate::module_code!("t0id") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { // The identifier is created in the browser, so the edge derives nothing. Ok(GeneratedEdgeCookie::default()) } - fn resolve_from_client( + async fn resolve_from_client( &self, input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { // The page posts the identifier it generated. Accept a well-formed UUID. let value = core::str::from_utf8(input.payload) @@ -564,6 +568,7 @@ mod tests { id: Option<&'static str>, } + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for ResolveHeaderModule { fn id(&self) -> &'static str { "resolve-header" @@ -573,17 +578,19 @@ mod tests { crate::module_code!("t0rh") } - fn generate( + async fn generate( &self, _request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } - fn resolve_from_client( + async fn resolve_from_client( &self, _input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, ) -> Result> { Ok(GeneratedEdgeCookie { id: self.id.map(str::to_owned), @@ -612,17 +619,17 @@ mod tests { /// Drives one resolve request through [`ResolveHeaderModule`], returning /// whatever the handler produced. - fn resolve_with_header_module( + async fn resolve_with_header_module( headers: &'static [(&'static str, &'static str)], id: Option<&'static str>, graph: Option<&crate::ec::kv::KvIdentityGraph>, ) -> Result, Report> { - resolve_request_with_header_module(headers, id, post(HEADER_MODULE_ID), graph) + resolve_request_with_header_module(headers, id, post(HEADER_MODULE_ID), graph).await } /// Drives `request` through [`ResolveHeaderModule`], threaded the way a /// composition root threads a module. - fn resolve_request_with_header_module( + async fn resolve_request_with_header_module( headers: &'static [(&'static str, &'static str)], id: Option<&'static str>, request: Request, @@ -638,17 +645,18 @@ mod tests { .expect("should build organic request"); let ec = EcContext::read_from_request(&settings, &organic, &services) .expect("should read EC context"); - handle_ec_resolve(&settings, request, &ec, graph) + handle_ec_resolve(&settings, request, &ec, graph, &services).await } - #[test] - fn resolve_rejects_a_reserved_response_effect_when_nothing_is_created() { + #[tokio::test] + async fn resolve_rejects_a_reserved_response_effect_when_nothing_is_created() { // The 204 path applies module headers too, so without the check a // browser-side module could set the managed identity cookie while // returning no identifier, walking past the identifier bounds, the // conflict check and the row-before-cookie rule. let outcome = - resolve_with_header_module(&[("set-cookie", "ts-ec=forged-value; Path=/")], None, None); + resolve_with_header_module(&[("set-cookie", "ts-ec=forged-value; Path=/")], None, None) + .await; let err = outcome.expect_err("a managed cookie effect should fail the request"); assert!( @@ -657,8 +665,8 @@ mod tests { ); } - #[test] - fn resolve_rejects_a_reserved_response_effect_on_the_created_path() { + #[tokio::test] + async fn resolve_rejects_a_reserved_response_effect_on_the_created_path() { // The same check has to cover the 200 path, where the module does // create and core is about to write its own cookie and headers. let graph = in_memory_graph(); @@ -666,7 +674,8 @@ mod tests { &[("x-ts-ec", "forged-value")], Some(HEADER_MODULE_ID), Some(&graph), - ); + ) + .await; let err = outcome.expect_err("a reserved header effect should fail the request"); assert!( @@ -675,8 +684,8 @@ mod tests { ); } - #[test] - fn resolve_accumulates_module_response_headers_with_its_own() { + #[tokio::test] + async fn resolve_accumulates_module_response_headers_with_its_own() { // The module's own cookies must add to what the handler already set, // never replace it. Replacing would keep only the last of a module's // own cookies, and would drop the `Cache-Control: no-store` core writes @@ -691,6 +700,7 @@ mod tests { Some(HEADER_MODULE_ID), Some(&graph), ) + .await .expect("should handle resolve"); assert_eq!( @@ -728,12 +738,13 @@ mod tests { ); } - #[test] - fn resolve_rejects_an_identifier_outside_the_cookie_bounds() { + #[tokio::test] + async fn resolve_rejects_an_identifier_outside_the_cookie_bounds() { // An identifier the cookie cannot carry is refused, never rewritten, // and leaves neither a cookie nor an identity-graph row behind. let graph = in_memory_graph(); let response = resolve_with_header_module(&[], Some("has a space"), Some(&graph)) + .await .expect("should handle resolve"); assert_eq!( response.status(), @@ -753,13 +764,14 @@ mod tests { ); } - #[test] - fn resolve_answers_503_when_the_identity_graph_write_fails() { + #[tokio::test] + async fn resolve_answers_503_when_the_identity_graph_write_fails() { // The row is written before the cookie, so when the write fails no // cookie is set, because withdrawal could not reach an identity with // no row. let graph = crate::ec::kv::KvIdentityGraph::failing("test-ec-store"); let response = resolve_with_header_module(&[], Some(HEADER_MODULE_ID), Some(&graph)) + .await .expect("should handle resolve"); assert_eq!( response.status(), @@ -772,8 +784,8 @@ mod tests { ); } - #[test] - fn resolve_refuses_an_advertised_oversized_body_before_reading_it() { + #[tokio::test] + async fn resolve_refuses_an_advertised_oversized_body_before_reading_it() { // A `Content-Length` over the limit is refused before the body is read, // whatever the body turns out to hold. let mut request = post(HEADER_MODULE_ID); @@ -783,6 +795,7 @@ mod tests { let graph = in_memory_graph(); let response = resolve_request_with_header_module(&[], Some(HEADER_MODULE_ID), request, Some(&graph)) + .await .expect("should handle resolve"); assert_eq!( response.status(), @@ -795,8 +808,8 @@ mod tests { ); } - #[test] - fn client_set_value_round_trips_through_the_ec_scenario() { + #[tokio::test] + async fn client_set_value_round_trips_through_the_ec_scenario() { // A client-generated first-party UUID, the value the browser posts back. const TEST_ID: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; @@ -818,7 +831,8 @@ mod tests { ec.ec_value().is_none(), "no EC should exist on the first visit" ); - ec.generate_if_needed(&settings, None) + ec.generate_if_needed(&settings, None, &services) + .await .expect("should run generation"); assert!( ec.ec_value().is_none(), @@ -830,7 +844,8 @@ mod tests { // value as the EC cookie. const CODED_TEST_ID: &str = "t0id~3f2504e0-4f89-41d3-9a0c-0305e82c3301"; let graph = in_memory_graph(); - let response = handle_ec_resolve(&settings, post(TEST_ID), &ec, Some(&graph)) + let response = handle_ec_resolve(&settings, post(TEST_ID), &ec, Some(&graph), &services) + .await .expect("should handle resolve"); assert_eq!( response.status(), @@ -873,8 +888,8 @@ mod tests { ); } - #[test] - fn resolve_sets_cookie_marker_and_no_store_when_word_matches_and_allowed() { + #[tokio::test] + async fn resolve_sets_cookie_marker_and_no_store_when_word_matches_and_allowed() { let settings = settings_with_client_fixed(); let graph = in_memory_graph(); let response = handle_ec_resolve( @@ -882,7 +897,9 @@ mod tests { post(FIXED_WORD), &gated(&settings, true), Some(&graph), + &noop_services(), ) + .await .expect("should handle resolve"); assert_eq!( @@ -939,8 +956,8 @@ mod tests { /// A resolve that creates nothing answers 204 and sets no cookie, whatever /// stopped it. - #[test] - fn resolve_answers_204_with_no_cookie_when_it_creates_nothing() { + #[tokio::test] + async fn resolve_answers_204_with_no_cookie_when_it_creates_nothing() { let mut no_module = create_test_settings(); no_module.ec.module = None; for (case, settings, body, allowed, has_graph) in [ @@ -974,7 +991,9 @@ mod tests { post(body), &gated(&settings, allowed), has_graph.then_some(&graph), + &noop_services(), ) + .await .expect("should handle resolve"); assert_eq!(response.status(), StatusCode::NO_CONTENT, "{case}"); @@ -988,7 +1007,7 @@ mod tests { /// A context read the way a composition root builds one, with the selected /// built-in module threaded through the services, at a location whose /// rules set storage without a signal so the gate is open. - fn resolve_with_threaded_module(settings: &Settings, body: &str) -> Response { + async fn resolve_with_threaded_module(settings: &Settings, body: &str) -> Response { let module = crate::ec::module::build_reusable_module(&settings.ec, None, None) .expect("should build the selected module") .expect("a built-in module is reusable"); @@ -1015,13 +1034,20 @@ mod tests { "the gate must be open for this to test anything" ); - handle_ec_resolve(settings, post(body), &ec, Some(&in_memory_graph())) - .expect("a valid selection should not be an error") + handle_ec_resolve( + settings, + post(body), + &ec, + Some(&in_memory_graph()), + &services, + ) + .await + .expect("a valid selection should not be an error") } - #[test] - fn resolve_answers_204_for_a_threaded_module_that_creates_nothing() { - let response = resolve_with_threaded_module(&create_test_settings(), FIXED_WORD); + #[tokio::test] + async fn resolve_answers_204_for_a_threaded_module_that_creates_nothing() { + let response = resolve_with_threaded_module(&create_test_settings(), FIXED_WORD).await; assert_eq!( response.status(), @@ -1030,9 +1056,10 @@ mod tests { ); } - #[test] - fn resolve_sets_the_cookie_for_a_threaded_client_fixed_module() { - let response = resolve_with_threaded_module(&settings_with_client_fixed(), FIXED_WORD); + #[tokio::test] + async fn resolve_sets_the_cookie_for_a_threaded_client_fixed_module() { + let response = + resolve_with_threaded_module(&settings_with_client_fixed(), FIXED_WORD).await; assert_eq!( response.status(), @@ -1052,8 +1079,8 @@ mod tests { } /// A request the endpoint cannot take is refused, and sets no cookie. - #[test] - fn resolve_refuses_a_request_it_cannot_take() { + #[tokio::test] + async fn resolve_refuses_a_request_it_cannot_take() { let settings = settings_with_client_fixed(); let oversized = "x".repeat(MAX_BODY_SIZE + 1); let own = Some("https://test-publisher.com"); @@ -1115,7 +1142,9 @@ mod tests { post_with(origin, content_type, body), &gated(&settings, true), Some(&graph), + &noop_services(), ) + .await .expect("should handle resolve"); assert_eq!(response.status(), status, "{case}"); @@ -1126,8 +1155,8 @@ mod tests { } } - #[test] - fn resolve_accepts_a_configured_extra_origin() { + #[tokio::test] + async fn resolve_accepts_a_configured_extra_origin() { let mut settings = settings_with_client_fixed(); settings .ec @@ -1139,8 +1168,15 @@ mod tests { Some("text/plain"), FIXED_WORD, ); - let response = handle_ec_resolve(&settings, request, &gated(&settings, true), Some(&graph)) - .expect("should handle resolve"); + let response = handle_ec_resolve( + &settings, + request, + &gated(&settings, true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); assert_eq!( response.status(), StatusCode::OK, @@ -1205,8 +1241,8 @@ mod tests { } } - #[test] - fn resolve_conflicts_when_a_different_identity_already_exists() { + #[tokio::test] + async fn resolve_conflicts_when_a_different_identity_already_exists() { let settings = settings_with_client_fixed(); let graph = in_memory_graph(); let existing = format!("{}.ABC123", "e".repeat(64)); @@ -1214,8 +1250,15 @@ mod tests { &settings, EcContext::new_for_test_gated(Some(existing), ConsentContext::default(), true), ); - let response = handle_ec_resolve(&settings, post(FIXED_WORD), &ec_context, Some(&graph)) - .expect("should handle resolve"); + let response = handle_ec_resolve( + &settings, + post(FIXED_WORD), + &ec_context, + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); assert_eq!( response.status(), StatusCode::CONFLICT, @@ -1314,7 +1357,7 @@ mod tests { /// One page view as the publisher path handles it: the context is read, /// organic generation runs, and the response is finalized before the /// browser stores what it sets. - fn view_page( + async fn view_page( settings: &Settings, services: &RuntimeServices, graph: &KvIdentityGraph, @@ -1328,7 +1371,8 @@ mod tests { send_cookies(&mut req, jar); let mut ec = EcContext::read_from_request(settings, &req, services).expect("should read EC context"); - ec.generate_if_needed(settings, Some(graph)) + ec.generate_if_needed(settings, Some(graph), services) + .await .expect("should run organic generation"); let mut response = Response::builder() .status(StatusCode::OK) @@ -1342,7 +1386,9 @@ mod tests { None, None, &mut response, - ); + services, + ) + .await; store_cookies(jar, &response); ec } @@ -1350,7 +1396,7 @@ mod tests { /// The demo page script's post as the adapter handles it: the context is /// read from the post, the handler answers, and the response is /// finalized before the browser stores what it sets. - fn post_fixed_word( + async fn post_fixed_word( settings: &Settings, services: &RuntimeServices, graph: &KvIdentityGraph, @@ -1360,8 +1406,9 @@ mod tests { send_cookies(&mut req, jar); let mut ec = EcContext::read_from_request(settings, &req, services).expect("should read EC context"); - let mut response = - handle_ec_resolve(settings, req, &ec, Some(graph)).expect("should handle resolve"); + let mut response = handle_ec_resolve(settings, req, &ec, Some(graph), services) + .await + .expect("should handle resolve"); ec_finalize_response( settings, &mut ec, @@ -1370,20 +1417,22 @@ mod tests { None, None, &mut response, - ); + services, + ) + .await; store_cookies(jar, &response); response.status() } - #[test] - fn the_demo_identity_is_recognized_and_keeps_its_marker() { + #[tokio::test] + async fn the_demo_identity_is_recognized_and_keeps_its_marker() { let settings = settings_with_client_fixed(); let services = services_with_record(StorageRecord::Grant); let graph = in_memory_graph(); let mut jar = Jar::new(); assert_eq!( - post_fixed_word(&settings, &services, &graph, &mut jar), + post_fixed_word(&settings, &services, &graph, &mut jar).await, StatusCode::OK, "the first post should create the identity" ); @@ -1393,7 +1442,7 @@ mod tests { "the resolve should set the marker" ); - let ec = view_page(&settings, &services, &graph, &mut jar); + let ec = view_page(&settings, &services, &graph, &mut jar).await; assert_eq!( ec.ec_value(), @@ -1407,8 +1456,8 @@ mod tests { ); } - #[test] - fn the_demo_page_does_not_post_again_on_the_next_view() { + #[tokio::test] + async fn the_demo_page_does_not_post_again_on_the_next_view() { let settings = settings_with_client_fixed(); let services = services_with_record(StorageRecord::Grant); let graph = in_memory_graph(); @@ -1416,32 +1465,32 @@ mod tests { let mut posts = 0; for _view in 0..3 { - view_page(&settings, &services, &graph, &mut jar); + view_page(&settings, &services, &graph, &mut jar).await; // The page script posts only when it finds no marker, which its // own tests pin in `ec_client_fixed/index.test.ts`. if !jar.contains_key(COOKIE_TS_EC_RESOLVED) { posts += 1; - post_fixed_word(&settings, &services, &graph, &mut jar); + post_fixed_word(&settings, &services, &graph, &mut jar).await; } } assert_eq!(posts, 1, "the page should post on the first view only"); } - #[test] - fn a_withdrawal_tombstones_the_demo_identity() { + #[tokio::test] + async fn a_withdrawal_tombstones_the_demo_identity() { let settings = settings_with_client_fixed(); let graph = in_memory_graph(); let mut jar = Jar::new(); let granted = services_with_record(StorageRecord::Grant); assert_eq!( - post_fixed_word(&settings, &granted, &graph, &mut jar), + post_fixed_word(&settings, &granted, &graph, &mut jar).await, StatusCode::OK, "the first post should create the identity" ); let withdrawn = services_with_record(StorageRecord::Withdraw); - let ec = view_page(&settings, &withdrawn, &graph, &mut jar); + let ec = view_page(&settings, &withdrawn, &graph, &mut jar).await; assert!( ec.storage_withdrawn(), diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 9814fcef7a..c649318263 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -38,7 +38,7 @@ use crate::settings::Settings; /// /// - [`TrustedServerError::EdgeCookie`] if module generation fails #[cfg(test)] -pub fn generate_ec_id( +pub async fn generate_ec_id( settings: &Settings, services: &RuntimeServices, request_headers: Option<&http::HeaderMap>, @@ -67,7 +67,9 @@ pub fn generate_ec_id( // This helper skips the permission gate, and the built-in module reads // neither the resolved permissions nor the consent context, so they are // not threaded here. - let generated = module.generate(&request_info, &IdentityInput::default())?; + let generated = module + .generate(&request_info, &IdentityInput::default(), services) + .await?; let generated = crate::ec::module::GeneratedEdgeCookie { id: generated .id @@ -209,7 +211,7 @@ pub fn recognized_ec_id( /// /// Returns an error if ID generation fails. #[cfg(test)] -pub(crate) fn get_or_generate_ec_id_from_http_request( +pub(crate) async fn get_or_generate_ec_id_from_http_request( settings: &Settings, services: &RuntimeServices, req: &Request, @@ -219,7 +221,7 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( } // If no existing EC ID found, generate a fresh one through the module. - let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + let ec_id = generate_ec_id(settings, services, Some(req.headers())).await?; if ec_id.is_some() { log::trace!("No existing EC ID found; generated a fresh EC ID"); } @@ -232,12 +234,12 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( /// /// Returns an error if ID generation fails. #[cfg(test)] -pub fn get_or_generate_ec_id( +pub async fn get_or_generate_ec_id( settings: &Settings, services: &RuntimeServices, req: &Request, ) -> Result, Report> { - get_or_generate_ec_id_from_http_request(settings, services, req) + get_or_generate_ec_id_from_http_request(settings, services, req).await } #[cfg(test)] @@ -296,11 +298,12 @@ mod tests { // normalized before it is hashed, is tested through `EcContext` in // `crate::ec`. - #[test] - fn test_generate_ec_id() { + #[tokio::test] + async fn test_generate_ec_id() { let settings: Settings = create_test_settings(); let ec_id = generate_ec_id(&settings, &noop_services(), None) + .await .expect("should generate EC ID") .expect("should configure the hmac module in test settings"); log::debug!("Generated EC ID: {}", ec_id); @@ -310,13 +313,14 @@ mod tests { ); } - #[test] - fn generate_ec_id_returns_none_when_no_module_is_configured() { + #[tokio::test] + async fn generate_ec_id_returns_none_when_no_module_is_configured() { let mut settings = create_test_settings(); // No module selected: Trusted Server runs statelessly. settings.ec.module = None; let id = generate_ec_id(&settings, &noop_services(), None) + .await .expect("generation should not error when no module is configured"); assert!( id.is_none(), @@ -324,15 +328,17 @@ mod tests { ); } - #[test] - fn test_generate_ec_id_uses_client_ip() { + #[tokio::test] + async fn test_generate_ec_id_uses_client_ip() { let settings = create_test_settings(); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .await .expect("should generate EC ID with client IP") .expect("should configure the hmac module in test settings"); let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + .await .expect("should generate EC ID without client IP") .expect("should configure the hmac module in test settings"); @@ -439,8 +445,8 @@ mod tests { ); } - #[test] - fn test_get_ec_id_with_header() { + #[tokio::test] + async fn test_get_ec_id_with_header() { let settings = create_test_settings(); let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); @@ -448,13 +454,14 @@ mod tests { assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .await .expect("should reuse header EC ID") .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } - #[test] - fn test_get_ec_id_with_cookie() { + #[tokio::test] + async fn test_get_ec_id_with_cookie() { let settings = create_test_settings(); let req = create_test_request(&[( header::COOKIE, @@ -465,6 +472,7 @@ mod tests { assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .await .expect("should reuse cookie EC ID") .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); @@ -485,8 +493,8 @@ mod tests { assert_eq!(ec_id, Some("existing_http_ec_id".to_string())); } - #[test] - fn test_get_or_generate_ec_id_from_http_request_reuses_cookie() { + #[tokio::test] + async fn test_get_or_generate_ec_id_from_http_request_reuses_cookie() { let settings = create_test_settings(); let req = http::Request::builder() .method("GET") @@ -499,6 +507,7 @@ mod tests { .expect("should build test request"); let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) + .await .expect("should reuse cookie EC ID from http request") .expect("an existing EC should be present"); @@ -512,12 +521,13 @@ mod tests { assert!(ec_id.is_none()); } - #[test] - fn test_get_or_generate_ec_id_generate_new() { + #[tokio::test] + async fn test_get_or_generate_ec_id_generate_new() { let settings = create_test_settings(); let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .await .expect("should get or generate EC ID") .expect("should configure the hmac module in test settings"); assert!(!ec_id.is_empty()); @@ -539,12 +549,13 @@ mod tests { ); } - #[test] - fn test_get_or_generate_ec_id_replaces_invalid_header() { + #[tokio::test] + async fn test_get_or_generate_ec_id_replaces_invalid_header() { let settings = create_test_settings(); let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .await .expect("should generate fresh ID on invalid header") .expect("should configure the hmac module in test settings"); assert_ne!( diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 69cfdd68a6..226dc681ca 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -30,9 +30,6 @@ pub enum TrustedServerError { #[display("Auction error: {message}")] Auction { message: String }, - /// GAM (Google Ad Manager) integration error. - #[display("GAM error: {message}")] - Gam { message: String }, /// GDPR consent handling error. /// /// **Note:** Unlike [`BadRequest`](Self::BadRequest), the detail `message` @@ -57,10 +54,6 @@ pub enum TrustedServerError { #[display("KV store error: {store_name} - {message}")] KvStore { store_name: String, message: String }, - /// Prebid integration error. - #[display("Prebid error: {message}")] - Prebid { message: String }, - /// Integration module error. #[display("Integration error ({integration}): {message}")] Integration { @@ -118,12 +111,10 @@ impl IntoHttpResponse for TrustedServerError { Self::Auction { .. } => StatusCode::BAD_GATEWAY, Self::BadRequest { .. } => StatusCode::BAD_REQUEST, Self::Configuration { .. } | Self::Settings { .. } => StatusCode::INTERNAL_SERVER_ERROR, - Self::Gam { .. } => StatusCode::BAD_GATEWAY, Self::GdprConsent { .. } => StatusCode::BAD_REQUEST, Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, Self::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, - Self::Prebid { .. } => StatusCode::BAD_GATEWAY, Self::Integration { .. } => StatusCode::BAD_GATEWAY, Self::Proxy { .. } => StatusCode::BAD_GATEWAY, Self::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, @@ -174,12 +165,6 @@ mod tests { }, StatusCode::BAD_GATEWAY, ), - ( - TrustedServerError::Gam { - message: String::from("request failed"), - }, - StatusCode::BAD_GATEWAY, - ), ( TrustedServerError::GdprConsent { message: String::from("missing consent string"), @@ -211,12 +196,6 @@ mod tests { }, StatusCode::SERVICE_UNAVAILABLE, ), - ( - TrustedServerError::Prebid { - message: String::from("adapter error"), - }, - StatusCode::BAD_GATEWAY, - ), ( TrustedServerError::Integration { integration: String::from("example-integration"), @@ -280,13 +259,11 @@ mod tests { StatusCode::INTERNAL_SERVER_ERROR } TrustedServerError::Auction { .. } => StatusCode::BAD_GATEWAY, - TrustedServerError::Gam { .. } => StatusCode::BAD_GATEWAY, TrustedServerError::GdprConsent { .. } => StatusCode::BAD_REQUEST, TrustedServerError::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, TrustedServerError::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, TrustedServerError::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, TrustedServerError::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, - TrustedServerError::Prebid { .. } => StatusCode::BAD_GATEWAY, TrustedServerError::Integration { .. } => StatusCode::BAD_GATEWAY, TrustedServerError::Proxy { .. } => StatusCode::BAD_GATEWAY, TrustedServerError::Forbidden { .. } => StatusCode::FORBIDDEN, @@ -330,12 +307,6 @@ mod tests { TrustedServerError::Auction { message: "bid timeout".into(), }, - TrustedServerError::Gam { - message: "api key invalid".into(), - }, - TrustedServerError::Prebid { - message: "adapter error".into(), - }, TrustedServerError::Integration { integration: "foo".into(), message: "connection refused".into(), @@ -408,12 +379,10 @@ mod tests { TrustedServerError::BadRequest { .. } | TrustedServerError::Configuration { .. } | TrustedServerError::Auction { .. } - | TrustedServerError::Gam { .. } | TrustedServerError::GdprConsent { .. } | TrustedServerError::InvalidUtf8 { .. } | TrustedServerError::InvalidHeaderValue { .. } | TrustedServerError::KvStore { .. } - | TrustedServerError::Prebid { .. } | TrustedServerError::Integration { .. } | TrustedServerError::Proxy { .. } | TrustedServerError::Forbidden { .. } @@ -505,18 +474,6 @@ mod tests { }, StatusCode::BAD_GATEWAY, ), - ( - TrustedServerError::Gam { - message: "gam failed".to_string(), - }, - StatusCode::BAD_GATEWAY, - ), - ( - TrustedServerError::Prebid { - message: "prebid failed".to_string(), - }, - StatusCode::BAD_GATEWAY, - ), ( TrustedServerError::Integration { integration: "test".to_string(), diff --git a/crates/trusted-server-core/src/host_rewrite.rs b/crates/trusted-server-core/src/host_rewrite.rs index 816dc52b8a..2324a8a3d2 100644 --- a/crates/trusted-server-core/src/host_rewrite.rs +++ b/crates/trusted-server-core/src/host_rewrite.rs @@ -6,7 +6,8 @@ /// /// This is used by both HTML (`__next_f` payloads) and Flight (`text/x-component`) rewriting to /// avoid corrupting unrelated hostnames. -pub(crate) fn rewrite_bare_host_at_boundaries( +#[must_use] +pub fn rewrite_bare_host_at_boundaries( text: &str, origin_host: &str, request_host: &str, diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index dea93ce3a2..c77b678b10 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -13,11 +13,10 @@ use lol_html::{ text, }; -use crate::integrations::datadome::{DATADOME_INTEGRATION_ID, DataDomeClientTagSuppressed}; -use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; use crate::integrations::{ AttributeRewriteOutcome, IntegrationAttributeContext, IntegrationDocumentState, - IntegrationHtmlContext, IntegrationRegistry, IntegrationScriptContext, ScriptRewriteAction, + IntegrationHtmlContext, IntegrationRegistry, IntegrationRequestState, IntegrationScriptContext, + ScriptRewriteAction, }; use crate::publisher::build_empty_bids_script; use crate::settings::Settings; @@ -97,13 +96,14 @@ pub struct HtmlProcessorConfig { /// Maximum bytes an integration may retain while processing one script or /// unresolved streaming group. pub max_buffered_body_bytes: usize, - /// Request-scoped conditional diagnostics delivery decision. - pub gpt_diagnostics: Option, + /// What modules left on the request for their page hooks, copied into the + /// document's state before parsing starts. Empty for a document that may + /// be stored and served to other readers, where nothing made for one + /// request may appear. + pub request_state: IntegrationRequestState, /// What the `` seam injects. Decided by the caller rather than inferred /// from [`Self::ad_slots_script`]. pub body_close: BodyCloseInjection, - /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. - pub suppress_datadome_client_side_tag: bool, /// Set when the document delivers a response-bound CSP nonce in its own markup. /// /// `None` on every path that cannot store a shared template, so an ordinary inline @@ -130,9 +130,8 @@ impl HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, - gpt_diagnostics: None, + request_state: IntegrationRequestState::default(), body_close: BodyCloseInjection::None, - suppress_datadome_client_side_tag: false, csp_nonce_observed: None, } } @@ -177,10 +176,10 @@ impl HtmlProcessorConfig { self } - /// Attach the request-scoped conditional diagnostics decision. + /// Attach what modules left on the request for their page hooks. #[must_use] - pub fn with_gpt_diagnostics(mut self, decision: Option) -> Self { - self.gpt_diagnostics = decision; + pub fn with_request_state(mut self, request_state: IntegrationRequestState) -> Self { + self.request_state = request_state; self } @@ -193,13 +192,6 @@ impl HtmlProcessorConfig { self.csp_nonce_observed = observed; self } - - /// Attach the request-scoped `DataDome` client-tag suppression decision. - #[must_use] - pub fn with_datadome_client_tag_suppression(mut self, suppress: bool) -> Self { - self.suppress_datadome_client_side_tag = suppress; - self - } } /// Create an HTML processor with URL replacement and integration hooks. @@ -216,9 +208,7 @@ impl HtmlProcessorConfig { pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { let stream_processor_factories = config.integrations.html_stream_processor_factories(); let document_state = IntegrationDocumentState::default(); - if config.suppress_datadome_client_side_tag { - document_state.get_or_insert_with(DATADOME_INTEGRATION_ID, || DataDomeClientTagSuppressed); - } + config.request_state.seed(&document_state); // Simplified URL patterns structure - stores only core data and generates variants on-demand struct UrlPatterns { @@ -291,7 +281,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let permissions_script = config.permissions_script.clone(); let body_close = config.body_close.clone(); let ad_bids_state = config.ad_bids_state.clone(); - let gpt_diagnostics = config.gpt_diagnostics.clone(); // No source-comment neutralization here: rewriting a publisher comment that happens // to match the reserved marker would change publisher content bytes. Collisions are @@ -322,7 +311,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); let permissions_script = permissions_script.clone(); - let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); @@ -347,31 +335,22 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso for insert in integrations.head_inserts(&ctx) { snippet.push_str(&insert); } - if let Some(bootstrap) = gpt_diagnostics - .as_ref() - .and_then(GptDiagnosticsRequestDecision::bootstrap_script) - { - snippet.push_str(&bootstrap); - } // Main bundle: core + non-deferred integrations (synchronous). - let immediate_ids = integrations.js_module_ids_immediate(); + let immediate_parts = integrations.js_parts_immediate(); let script_attributes = integrations.tsjs_script_tag_attributes(); snippet.push_str(&tsjs::tsjs_script_tag_with_attributes( - &immediate_ids, + &immediate_parts, &script_attributes, )); - // Active diagnostics loads synchronously after core so its - // GPT listeners precede publisher scripts in the origin head. - if let Some(module_tag) = gpt_diagnostics - .as_ref() - .and_then(GptDiagnosticsRequestDecision::module_script_tag) - { - snippet.push_str(&module_tag); + // What an integration loads after the bundle and ahead of + // the page's own scripts in the origin head. + for insert in integrations.after_bundle_inserts(&ctx) { + snippet.push_str(&insert); } // Deferred bundles: large modules like prebid loaded after // HTML parsing completes. Empty when none are enabled. - let deferred_ids = integrations.js_module_ids_deferred(); - snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); + let deferred_parts = integrations.js_parts_deferred(); + snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_parts)); el.prepend(&snippet, ContentType::Html); injected_tsjs.set(true); } @@ -776,8 +755,7 @@ mod tests { permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), } } @@ -907,21 +885,22 @@ mod tests { } #[test] - fn integration_head_injector_marks_only_attribution_enabled_gpt_bundle() { - fn process(gpt_config: Option<(bool, bool)>) -> String { - let integrations = if let Some((enabled, gam_attribution_enabled)) = gpt_config { + fn integration_head_injector_marks_only_the_bundle_it_asks_to_mark() { + use crate::integrations::registry_test_support::tag_fixture as tag; + + fn process(mark_bundle: Option) -> String { + let integrations = if let Some(mark_bundle) = mark_bundle { let mut settings = create_test_settings(); settings - .integrations - .insert_config( - "gpt", + .insert_module_config( + "testing", + tag::MODULE, &json!({ - "enabled": enabled, - "gam_attribution_enabled": gam_attribution_enabled + "mark_bundle": mark_bundle }), ) - .expect("should insert GPT config"); - IntegrationRegistry::new(&settings).expect("should build GPT registry") + .expect("should insert the stand-in's settings"); + IntegrationRegistry::new(&settings).expect("should build the registry") } else { IntegrationRegistry::empty_for_tests() }; @@ -935,39 +914,35 @@ mod tests { String::from_utf8(output).expect("should produce valid UTF-8") } - let attributed = process(Some((true, true))); - let unattributed = process(Some((true, false))); - let disabled_gpt = process(Some((false, true))); - let without_gpt = process(None); + let marked = process(Some(true)); + let unmarked = process(Some(false)); + let without_the_integration = process(None); - for html in [&attributed, &unattributed, &disabled_gpt, &without_gpt] { + for html in [&marked, &unmarked, &without_the_integration] { assert_eq!( html.matches("id=\"trustedserver-js\"").count(), 1, "should emit exactly one publisher bundle tag: {html}" ); } + let attribute = format!("{}=\"true\"", tag::BUNDLE_ATTRIBUTE); assert!( - attributed.contains("data-ts-gam-attribution=\"true\""), - "should mark only an attribution-enabled GPT publisher bundle" - ); - assert!( - !unattributed.contains("data-ts-gam-attribution"), - "should leave an attribution-disabled GPT publisher bundle unmarked" + marked.contains(&attribute), + "should mark the bundle an injector asks to mark" ); assert!( - !disabled_gpt.contains("data-ts-gam-attribution"), - "should let the GPT master switch suppress attribution metadata" + !unmarked.contains(tag::BUNDLE_ATTRIBUTE), + "should leave the bundle unmarked when the injector asks for nothing" ); assert!( - !without_gpt.contains("data-ts-gam-attribution"), - "should leave a non-GPT publisher bundle unmarked" + !without_the_integration.contains(tag::BUNDLE_ATTRIBUTE), + "should leave the bundle unmarked when no section selects the integration" ); - let head_insert_index = attributed - .find("window.__tsjs_installGptShim") - .expect("should include the GPT head insert"); - let publisher_bundle_index = attributed + let head_insert_index = marked + .find(tag::HEAD_FLAG) + .expect("should include the integration's head insert"); + let publisher_bundle_index = marked .find("id=\"trustedserver-js\"") .expect("should include the publisher bundle"); assert!( @@ -977,33 +952,16 @@ mod tests { } #[test] - fn active_gpt_diagnostics_loads_standalone_after_unified_bundle_once() { + fn what_a_module_left_on_the_request_is_written_around_the_unified_bundle_once() { + use crate::integrations::registry_test_support::request_fixture; + let html = "Test"; let mut settings = create_test_settings(); - settings - .integrations - .insert_config("gpt_diagnostics", &json!({ "enabled": true })) - .expect("should insert GPT diagnostics config"); - - let mut request = http::Request::builder() - .method(http::Method::GET) - .uri("https://publisher.example/page?ts_console=1") - .header("sec-fetch-dest", "document") - .body(edgezero_core::body::Body::empty()) - .expect("should build activation request"); - let decision = - crate::integrations::gpt_diagnostics::prepare_request(&settings, &mut request) - .expect("should prepare diagnostics request"); + settings.select_module("testing", request_fixture::MODULE); let mut config = create_test_config(); - config.integrations = IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), - ) - .expect("should build integration registry"); - config.gpt_diagnostics = Some(decision); + config.integrations = + IntegrationRegistry::new(&settings).expect("should build integration registry"); + config.request_state = request_fixture::marked(); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -1018,14 +976,14 @@ mod tests { .process(Cursor::new(html.as_bytes()), &mut output) .expect("should process HTML"); let processed = String::from_utf8(output).expect("should produce valid UTF-8"); - let bootstrap_marker = "__tsjs_gpt_diagnostics_active"; + let head_marker = request_fixture::HEAD_FLAG; let bundle_marker = "id=\"trustedserver-js\""; - let diagnostics_marker = "tsjs-gpt_diagnostics.min.js"; + let module_marker = request_fixture::MODULE_FILE; assert_eq!( - processed.matches(bootstrap_marker).count(), + processed.matches(head_marker).count(), 1, - "should inject the diagnostics bootstrap once" + "should write the module's head insert once" ); assert_eq!( processed.matches(bundle_marker).count(), @@ -1033,26 +991,26 @@ mod tests { "should inject the immediate TSJS bundle once" ); assert_eq!( - processed.matches(diagnostics_marker).count(), + processed.matches(module_marker).count(), 1, - "should inject one standalone diagnostics module" + "should write the module's after-bundle insert once" ); - let bootstrap_index = processed - .find(bootstrap_marker) - .expect("should include diagnostics bootstrap"); + let head_index = processed + .find(head_marker) + .expect("should include the head insert"); 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"); + let module_index = processed + .find(module_marker) + .expect("should include the after-bundle insert"); assert!( - bootstrap_index < bundle_index, - "should activate before core executes" + head_index < bundle_index, + "should write the head insert before the bundle executes" ); assert!( - bundle_index < diagnostics_index, - "should load diagnostics after core" + bundle_index < module_index, + "should write the after-bundle insert after the bundle" ); } @@ -1119,62 +1077,6 @@ mod tests { assert_eq!(config.request_scheme, "https"); } - #[test] - fn suppressed_datadome_tag_preserves_and_rewrites_publisher_tag() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "datadome", - &json!({ - "enabled": true, - "client_side_key": "test-client-key", - }), - ) - .expect("should configure DataDome integration"); - let registry = IntegrationRegistry::new(&settings) - .expect("should create integration registry with DataDome"); - let config = HtmlProcessorConfig::from_settings( - &settings, - ®istry, - "origin.example.com", - "test.example.com", - "https", - ) - .with_datadome_client_tag_suppression(true); - let mut processor = create_html_processor(config); - - let output = processor - .process_chunk( - br#"content"#, - true, - ) - .expect("should process HTML"); - let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); - - assert!( - !html.contains("window.ddjskey"), - "should omit the DataDome client configuration" - ); - assert!( - html.contains("id=\"publisher-datadome\""), - "should preserve the publisher-originated DataDome tag" - ); - assert!( - html.contains("src=\"/integrations/datadome/tags.js\""), - "should rewrite the publisher-originated DataDome tag" - ); - assert!( - !html.contains("https://js.datadome.co/tags.js"), - "should remove the original third-party DataDome URL" - ); - assert_eq!( - html.matches("/integrations/datadome/tags.js").count(), - 1, - "should leave exactly one publisher-originated DataDome tag" - ); - } - #[test] fn test_real_publisher_html() { // Test with publisher HTML from test_publisher.html @@ -1252,61 +1154,6 @@ mod tests { ); } - #[test] - fn test_integration_registry_rewrites_integration_scripts() { - let html = r#" - - "#; - - let mut settings = Settings::default(); - let shim_src = "https://edge.example.com/static/testlight.js".to_owned(); - settings - .integrations - .insert_config( - "testlight", - &json!({ - "enabled": true, - "endpoint": "https://example.com/openrtb2/auction", - "rewrite_scripts": true, - "shim_src": shim_src, - }), - ) - .expect("should insert testlight config"); - - let registry = IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), - ) - .expect("should create registry"); - let mut config = create_test_config(); - config.integrations = registry; - - let processor = create_html_processor(config); - let pipeline_config = PipelineConfig { - input_compression: Compression::None, - output_compression: Compression::None, - chunk_size: 8192, - }; - let mut pipeline = StreamingPipeline::new(pipeline_config, processor); - - let mut output = Vec::new(); - let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output); - result.unwrap(); - - let processed = String::from_utf8_lossy(&output); - assert!( - processed.contains(&shim_src), - "Integration shim should replace integration script reference" - ); - assert!( - !processed.contains("cdn.testlight.com"), - "Original integration URL should be removed" - ); - } - #[test] fn test_real_publisher_html_with_gzip() { use flate2::Compression as GzCompression; @@ -1582,8 +1429,7 @@ mod tests { permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1660,8 +1506,7 @@ mod tests { permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1700,8 +1545,7 @@ mod tests { permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut processor = create_html_processor(config); // Malformed HTML with two elements (common in CMS template pages) @@ -1739,8 +1583,7 @@ mod tests { permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1796,8 +1639,7 @@ mod tests { permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1827,8 +1669,7 @@ mod tests { permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1853,8 +1694,7 @@ mod tests { permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), } } @@ -1999,50 +1839,6 @@ mod tests { } } - #[test] - fn nextjs_output_overflow_restores_in_progress_script_at_every_split() { - let mut settings = create_test_settings(); - settings.integrations.insert( - "nextjs".to_owned(), - json!({"enabled": true, "max_combined_payload_bytes": 128}), - ); - let registry = IntegrationRegistry::with_plan( - &settings, - Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile plan")), - ) - .expect("should create registry"); - let first = r#""#; - let script = r#"self.__next_f.push([1,"c"])"#; - let padding = "x".repeat(129); - let expected = format!("{first}{padding}"); - - for split in 1..script.len() { - let mut config = create_test_config(); - config.integrations = registry.clone(); - let mut processor = create_html_processor(config); - let mut output = processor - .process_chunk(first.as_bytes(), false) - .expect("should process unresolved RSC group"); - let second = format!("{padding}", &script[split..]); - output.extend( - processor - .process_chunk(third.as_bytes(), true) - .expect("should finish bypassed script"), - ); - assert_eq!( - String::from_utf8(output).expect("should retain UTF-8"), - expected, - "should restore all original bytes when overflow occurs at script split {split}" - ); - } - } - #[test] fn a_nonce_bearing_meta_policy_is_observed() { let observed = Arc::new(AtomicBool::new(false)); @@ -2115,8 +1911,7 @@ mod tests { permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: IntegrationRequestState::default(), }; let source = format!(r#""#); diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index b1bea7cd84..9b7990ae38 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -30,6 +30,20 @@ use crate::proxy::{ProxyRequestConfig, proxy_request}; use crate::settings::{IntegrationConfig, Settings}; pub(crate) const JS_ASSET_PROXY_INTEGRATION_ID: &str = "js_asset_proxy"; + +/// The name this module is selected by, in `[proxy]`. +pub const MODULE: &str = "js_asset_proxy"; + +/// The builder the registry runs when a section selects [`MODULE`]. +pub(crate) const BUILDER: crate::integrations::IntegrationBuilder = + crate::integrations::IntegrationBuilder::new( + JS_ASSET_PROXY_INTEGRATION_ID, + crate::integrations::CORE_SOURCE, + register, + validate, + ) + .with_module_name(MODULE) + .selected_in("proxy"); const JS_ASSET_CONTENT_TYPE: &str = "application/javascript; charset=utf-8"; const X_CONTENT_TYPE_OPTIONS_NOSNIFF: &str = "nosniff"; const ERROR_ORIGIN_UNREACHABLE: &str = "js-asset-origin-unreachable"; @@ -37,10 +51,8 @@ const ERROR_ORIGIN_STATUS: &str = "js-asset-origin-status"; /// Configuration for the JavaScript asset proxy integration. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct JsAssetProxyConfig { - /// Enables or disables the integration. - #[serde(default)] - pub enabled: bool, /// Optional downstream cache TTL override for every asset. #[serde(default)] pub cache_ttl_seconds: Option, @@ -88,18 +100,16 @@ impl JsAssetProxyConfig { } } -impl IntegrationConfig for JsAssetProxyConfig { - fn is_enabled(&self) -> bool { - self.enabled - } -} +impl IntegrationConfig for JsAssetProxyConfig {} impl Validate for JsAssetProxyConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = ValidationErrors::new(); errors.merge_self("assets", self.assets.validate()); - if self.enabled && self.assets.is_empty() { + // A selected integration with nothing to serve is a configuration + // mistake, so the block has to list at least one asset. + if self.assets.is_empty() { errors.add("assets", ValidationError::new("empty_assets")); } @@ -446,9 +456,7 @@ impl JsAssetProxyIntegration { fn build( settings: &Settings, ) -> Result>, Report> { - let Some(mut config) = - settings.integration_config::(JS_ASSET_PROXY_INTEGRATION_ID)? - else { + let Some(mut config) = settings.module_config::(MODULE)? else { return Ok(None); }; config.normalize_origin_urls(); @@ -456,11 +464,24 @@ fn build( Ok(Some(JsAssetProxyIntegration::new(config))) } +/// Validates the JavaScript asset proxy configuration for deployment and +/// reports whether a section selects the integration's module. +/// +/// # Errors +/// +/// Returns an error when the configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .module_config::(MODULE) + .map(|config| config.is_some()) +} + /// Register the JavaScript asset proxy integration. /// /// # Errors /// -/// Returns an error when the integration is enabled with invalid configuration. +/// Returns an error when the integration runs with invalid configuration. pub fn register( settings: &Settings, ) -> Result, Report> { @@ -600,7 +621,6 @@ mod tests { fn config_with_assets(assets: Vec) -> JsAssetProxyConfig { JsAssetProxyConfig { - enabled: true, cache_ttl_seconds: None, assets, } @@ -638,8 +658,7 @@ mod tests { ad_slots_script: None, ad_bids_state: Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + request_state: crate::integrations::IntegrationRequestState::default(), permissions_script: None, }); let pipeline_config = PipelineConfig { @@ -657,34 +676,20 @@ mod tests { } #[test] - fn disabled_config_does_not_register_routes() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - JS_ASSET_PROXY_INTEGRATION_ID, - &json!({ - "enabled": false, - "assets": [{ - "path": "/assets/vendor.js", - "origin_url": "https://cdn.example.com/vendor.js" - }] - }), - ) - .expect("should insert integration config"); + fn an_integration_that_is_not_named_registers_no_routes() { + let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should build registry"); assert!( !registry.has_route(&Method::GET, "/assets/vendor.js"), - "disabled integration should not register asset route" + "an integration that is not named should register no asset route" ); } #[test] fn enabled_config_requires_at_least_one_asset() { let config = JsAssetProxyConfig { - enabled: true, cache_ttl_seconds: None, assets: Vec::new(), }; @@ -846,104 +851,117 @@ mod tests { #[test] fn js_asset_proxy_rewriter_takes_precedence_over_native_rewriters() { let mut settings = create_test_settings(); + settings.select_module( + "testing", + crate::integrations::registry_test_support::tag_fixture::MODULE, + ); settings - .integrations - .insert_config("gpt", &json!({ "enabled": true })) - .expect("should insert GPT config"); - settings - .integrations - .insert_config( - JS_ASSET_PROXY_INTEGRATION_ID, + .insert_module_config( + "proxy", + "js_asset_proxy", &json!({ - "enabled": true, "assets": [{ - "path": "/assets/gpt.js", - "origin_url": "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + "path": "/assets/sdk.js", + "origin_url": crate::integrations::registry_test_support::tag_fixture::SCRIPT_URL, "proxy": "enabled" }] }), ) .expect("should insert JS asset proxy config"); let registry = IntegrationRegistry::new(&settings).expect("should build registry"); - let html = r#""#; + let html = format!( + r#""#, + crate::integrations::registry_test_support::tag_fixture::SCRIPT_URL + ); - let processed = process_html_with_registry(html, registry); + let processed = process_html_with_registry(&html, registry); assert!( - processed.contains(r#""#), - "JS asset proxy should rewrite before GPT native rewriter: {processed}" + processed.contains(r#""#), + "JS asset proxy should rewrite before the integration's own rewriter: {processed}" ); assert!( - !processed.contains("/integrations/gpt/script"), - "GPT native rewrite should not override JS asset proxy" + !processed.contains( + crate::integrations::registry_test_support::tag_fixture::FIRST_PARTY_SCRIPT + ), + "the integration's own rewrite should not override JS asset proxy" ); } #[test] fn js_asset_proxy_blocking_takes_precedence_over_native_rewriters() { let mut settings = create_test_settings(); + settings.select_module( + "testing", + crate::integrations::registry_test_support::tag_fixture::MODULE, + ); settings - .integrations - .insert_config("gpt", &json!({ "enabled": true })) - .expect("should insert GPT config"); - settings - .integrations - .insert_config( - JS_ASSET_PROXY_INTEGRATION_ID, + .insert_module_config( + "proxy", + "js_asset_proxy", &json!({ - "enabled": true, "assets": [{ - "path": "/assets/gpt.js", - "origin_url": "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + "path": "/assets/sdk.js", + "origin_url": crate::integrations::registry_test_support::tag_fixture::SCRIPT_URL, "proxy": "blocked" }] }), ) .expect("should insert JS asset proxy config"); let registry = IntegrationRegistry::new(&settings).expect("should build registry"); - let html = r#""#; + let html = format!( + r#""#, + crate::integrations::registry_test_support::tag_fixture::SCRIPT_URL + ); - let processed = process_html_with_registry(html, registry); + let processed = process_html_with_registry(&html, registry); assert!( - !processed.contains("googletag.cmd"), - "blocked JS asset should remove the script element before GPT can rewrite it" + !processed.contains("vendor.cmd"), + "blocked JS asset should remove the script element before the integration can rewrite it" ); assert!( - !processed.contains("/integrations/gpt/script"), - "GPT native rewrite should not keep a blocked script" + !processed.contains( + crate::integrations::registry_test_support::tag_fixture::FIRST_PARTY_SCRIPT + ), + "the integration's own rewrite should not keep a blocked script" ); } #[test] fn disabled_js_asset_proxy_candidate_allows_native_rewriters() { let mut settings = create_test_settings(); + settings.select_module( + "testing", + crate::integrations::registry_test_support::tag_fixture::MODULE, + ); settings - .integrations - .insert_config("gpt", &json!({ "enabled": true })) - .expect("should insert GPT config"); - settings - .integrations - .insert_config( - JS_ASSET_PROXY_INTEGRATION_ID, + .insert_module_config( + "proxy", + "js_asset_proxy", &json!({ - "enabled": true, "assets": [{ - "path": "/assets/gpt.js", - "origin_url": "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + "path": "/assets/sdk.js", + "origin_url": crate::integrations::registry_test_support::tag_fixture::SCRIPT_URL, "proxy": "disabled" }] }), ) .expect("should insert JS asset proxy config"); let registry = IntegrationRegistry::new(&settings).expect("should build registry"); - let html = r#""#; + let html = format!( + r#""#, + crate::integrations::registry_test_support::tag_fixture::SCRIPT_URL + ); - let processed = process_html_with_registry(html, registry); + let processed = process_html_with_registry(&html, registry); assert!( - processed.contains(r#""#), - "disabled JS asset proxy entries should not suppress native integration rewrites" + processed.contains(&format!( + r#""#, + crate::integrations::registry_test_support::tag_fixture::FIRST_PARTY_SCRIPT + )), + "disabled JS asset proxy entries should not suppress an integration's own rewrite" ); } @@ -1059,10 +1077,10 @@ mod tests { config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" - [integrations.js_asset_proxy] - enabled = true + [proxy] + modules = ["js_asset_proxy"] - [[integrations.js_asset_proxy.assets]] + [[proxy.js_asset_proxy.assets]] path = "/assets/vendor.js" origin_url = "https://cdn.example.com/vendor.js" proxy = "passthrough" @@ -1071,7 +1089,7 @@ mod tests { assert!( settings - .integration_config::(JS_ASSET_PROXY_INTEGRATION_ID) + .module_config::(MODULE) .is_err(), "unknown proxy mode should fail deserialization" ); @@ -1081,11 +1099,10 @@ mod tests { fn exact_configured_routes_are_registered() { let mut settings = create_test_settings(); settings - .integrations - .insert_config( - JS_ASSET_PROXY_INTEGRATION_ID, + .insert_module_config( + "proxy", + "js_asset_proxy", &json!({ - "enabled": true, "assets": [ { "path": "/assets/vendor.js", @@ -1282,11 +1299,10 @@ mod tests { fn configured_origin_urls_are_canonicalized_for_matching_and_duplicates() { let mut settings = create_test_settings(); settings - .integrations - .insert_config( - JS_ASSET_PROXY_INTEGRATION_ID, + .insert_module_config( + "proxy", + "js_asset_proxy", &json!({ - "enabled": true, "assets": [{ "path": "/assets/vendor.js", "origin_url": "HTTPS://CDN.EXAMPLE.COM:443/vendor.js" diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 820e2c24b8..f12f4671b5 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -5,39 +5,30 @@ use std::time::Duration; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use futures::StreamExt as _; +use http::{Request, Response}; use url::Url; +use crate::auction::AuctionPlan; +use crate::auction::demand::{AdServerImplementation, DemandImplementation}; use crate::error::TrustedServerError; use crate::platform::{DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, RuntimeServices}; use crate::settings::Settings; -pub mod adserver_mock; -pub mod aps; -pub mod datadome; -pub mod didomi; -pub mod google_tag_manager; -pub mod gpt; -pub mod gpt_diagnostics; pub mod js_asset_proxy; -pub mod lockr; -pub mod nextjs; -pub mod osano; -pub mod permutive; -pub mod prebid; mod registry; -pub mod sourcepoint; -pub mod testlight; +#[cfg(test)] +pub(crate) use registry::test_support as registry_test_support; pub use registry::{ - AttributeRewriteAction, AttributeRewriteOutcome, HeaderMutation, HeaderMutationMode, - IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationDocumentState, - IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, + AttributeRewriteAction, AttributeRewriteOutcome, CarriedJsModule, HeaderMutation, + HeaderMutationMode, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationDocumentState, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationHtmlStreamContext, IntegrationHtmlStreamProcessorFactory, IntegrationMetadata, IntegrationProxy, IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry, - IntegrationRequestFilter, IntegrationScriptContext, IntegrationScriptRewriter, - ProxyDispatchInput, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, - RequestFilterRegistryInput, RequestFilterRegistryOutcome, ScriptRewriteAction, - ScriptTextAccumulator, + IntegrationRequestFilter, IntegrationRequestState, IntegrationScriptContext, + IntegrationScriptRewriter, ProxyDispatchInput, RequestFilterDecision, RequestFilterEffects, + RequestFilterInput, RequestFilterRegistryInput, RequestFilterRegistryOutcome, + ScriptRewriteAction, ScriptTextAccumulator, }; /// Registers or retrieves a platform backend for the given URL. @@ -46,11 +37,13 @@ pub use registry::{ /// 15-second first-byte timeout, and delegates to /// [`crate::platform::PlatformBackend::ensure`]. /// +/// Public for the same reason as [`ensure_integration_backend_with_timeout`]. +/// /// # Errors /// /// Returns an error when `url` cannot be parsed, is missing a host, or the /// backend registration fails. -pub(crate) fn ensure_integration_backend( +pub fn ensure_integration_backend( services: &RuntimeServices, url: &str, integration: &'static str, @@ -77,11 +70,15 @@ pub(crate) fn ensure_integration_backend( /// given `first_byte_timeout`, and delegates to /// [`crate::platform::PlatformBackend::ensure`]. /// +/// Public because an integration crate outside this one cannot reach an +/// upstream without registering a backend first, and a crate rolling its own +/// registration would produce a different backend name for the same URL. +/// /// # Errors /// /// Returns an error when `url` cannot be parsed, is missing a host, or the /// backend registration fails. -pub(crate) fn ensure_integration_backend_with_timeout( +pub fn ensure_integration_backend_with_timeout( services: &RuntimeServices, url: &str, integration: &'static str, @@ -106,11 +103,15 @@ pub(crate) fn ensure_integration_backend_with_timeout( /// Parses `url`, builds a [`PlatformBackendSpec`], and delegates to /// [`crate::platform::PlatformBackend::predict_name`]. /// +/// Public for the same reason as [`ensure_integration_backend_with_timeout`], +/// and the two have to agree, so a crate outside this one uses this function +/// and does not derive a name of its own. +/// /// # Errors /// /// Returns an error when the URL cannot be parsed, is missing a host, or the /// platform backend cannot predict a name for the spec. -pub(crate) fn predict_integration_backend_name( +pub fn predict_integration_backend_name( services: &RuntimeServices, url: &str, integration: &'static str, @@ -164,22 +165,34 @@ fn integration_backend_spec( } /// Maximum body size accepted by integration proxy endpoints (256 KiB). -pub(crate) const INTEGRATION_MAX_BODY_BYTES: usize = 256 * 1024; +/// +/// Public so every integration crate bounds a request body at one size. +pub const INTEGRATION_MAX_BODY_BYTES: usize = 256 * 1024; -/// Maximum response body size from RTB providers (prebid, aps, mediator). -pub(crate) const UPSTREAM_RTB_MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +/// Maximum response body size from RTB providers (prebid, aps, ad server). +/// +/// Public so an integration crate outside this one bounds an upstream +/// response at the size the built-in ones do. +pub const UPSTREAM_RTB_MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; /// Maximum response body size from SDK/proxy integrations. -pub(crate) const UPSTREAM_SDK_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; +/// +/// Public so every integration crate bounds an upstream script or proxied +/// response at one size. +pub const UPSTREAM_SDK_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; /// Drains an [`EdgeBody`] into a byte vector, rejecting bodies larger than /// `max_bytes` with [`TrustedServerError::RequestTooLarge`]. /// +/// Public because an integration crate outside this one reads request bodies +/// too, and an unbounded read of a client's body is not a fault each crate +/// should solve again. +/// /// # Errors /// /// Returns an error when: /// - The body exceeds `max_bytes`. /// - A streaming body chunk cannot be read (mapped to an `Integration` error). -pub(crate) async fn collect_body_bounded( +pub async fn collect_body_bounded( body: EdgeBody, max_bytes: usize, integration: &'static str, @@ -226,7 +239,7 @@ pub(crate) async fn collect_body_bounded( /// /// Use this for upstream (provider/integration) response bodies to bound /// memory usage when a third-party server misbehaves. Unlike -/// [`collect_body_bounded`], oversized bodies are classified as +/// `collect_body_bounded`, oversized bodies are classified as /// [`TrustedServerError::Integration`] (502 `BAD_GATEWAY`) rather than /// [`TrustedServerError::RequestTooLarge`] (413). /// @@ -234,12 +247,16 @@ pub(crate) async fn collect_body_bounded( /// because the size check runs after each chunk is materialized. Fastly /// H2/H3 chunks are ≤ 16 KiB in practice, making the overshoot negligible. /// +/// Public because an integration crate outside this one reads upstream +/// responses too, and an unbounded read of a misbehaving upstream is not a +/// fault each crate should solve again. +/// /// # Errors /// /// Returns an error when: /// - The body exceeds `max_bytes` (mapped to [`TrustedServerError::Integration`]). /// - A streaming body chunk cannot be read (same error type). -pub(crate) async fn collect_response_bounded( +pub async fn collect_response_bounded( body: EdgeBody, max_bytes: usize, integration: &'static str, @@ -281,69 +298,460 @@ pub(crate) async fn collect_response_bounded( } } -type IntegrationBuilderFn = +/// Builds an integration's registration from settings, or `None` when the +/// settings give it nothing to register. +/// +/// The registry calls this only for an integration a section selects, so an integration runs exactly when an operator names it. +pub type IntegrationBuilderFn = fn(&Settings) -> Result, Report>; -pub(crate) struct IntegrationBuilder { +/// Validates an integration's configuration for deployment and reports +/// whether a section selects it. +/// +/// Runs for every builder, named or not, so one builder's rules cannot be +/// skipped by the order the builders happen to be in. At deploy time secret +/// fields hold secret-store key names rather than values, so a validator must +/// not depend on a resolved secret. +pub type IntegrationValidateFn = fn(&Settings) -> Result>; + +/// Prepares a request before routing, for every routed request except the +/// health check. +/// +/// Runs whether or not a section selects the integration, so one +/// can strip its own reserved query or cookie in a deployment that does not +/// run it. +pub type IntegrationPrepareRequestFn = + fn(&Settings, &mut Request) -> Result<(), Report>; + +/// Finishes the response the page path returns for one request, from what +/// the module's request hooks left for it in the request's +/// [`IntegrationRequestState`]. +/// +/// Runs whether or not a section selects the integration, as the preparer +/// does, and has nothing to do for a request its module left nothing on. +pub type IntegrationFinalizeResponseFn = fn(&IntegrationRequestState, &mut Response); + +/// Builds a module's registration from the settings and the compiled auction +/// plan, or `None` when the two give it nothing to register. +/// +/// Runs for every builder that has one, whether or not a section selects the +/// module, because a module registered this way follows what the plan +/// selects and decides for itself whether it runs. +pub type IntegrationPlanRegistrationFn = + fn( + &Settings, + &AuctionPlan, + ) -> Result, Report>; + +/// Checks a module's configuration against the compiled auction plan, for a +/// rule that depends on what the plan selects. +/// +/// Runs for every builder that has one, when a deployment is validated and +/// as the settings load. +pub type IntegrationPlanValidateFn = + fn(&Settings, &AuctionPlan) -> Result<(), Report>; + +/// A setting in a module's own table that holds the name of a secret. +/// +/// The name is looked up in the default secret store as the settings load, +/// and the setting holds the secret from then on. +#[derive(Clone, Copy, Debug)] +pub struct ModuleSecretSetting { + /// Where the setting sits inside the module's table, one name for each + /// level. + pub path: &'static [&'static str], + /// Whether the module's table, as written, puts the setting to use. A + /// setting not in use is cleared as the settings load and is not looked + /// up, and one in use has to name a key before a deployment is accepted. + pub in_use: fn(&serde_json::Map) -> bool, +} + +/// Source label for the built-in integrations. +pub const CORE_SOURCE: &str = "trusted-server-core"; + +/// A named factory for one integration, the unit an adapter or a vendor crate +/// hands to [`IntegrationRegistry::with_plan_and_registrations`]. +/// +/// # Examples +/// +/// ``` +/// use error_stack::Report; +/// use trusted_server_core::error::TrustedServerError; +/// use std::sync::Arc; +/// +/// use trusted_server_core::auction::compile_auction_plan; +/// use trusted_server_core::integrations::{ +/// IntegrationBuilder, IntegrationRegistration, IntegrationRegistry, +/// }; +/// use trusted_server_core::settings::Settings; +/// +/// fn build( +/// _settings: &Settings, +/// ) -> Result, Report> { +/// Ok(Some(IntegrationRegistration::builder("example").build())) +/// } +/// +/// fn validate(_settings: &Settings) -> Result> { +/// Ok(true) +/// } +/// +/// # fn demo(settings: &Settings) -> Result<(), Report> { +/// let builder = IntegrationBuilder::new("example", "example-crate", build, validate) +/// .with_module_name("testing.example"); +/// // The registry builds a module a section selects, so a deployment that +/// // wants this one writes `[testing] modules = ["example"]`. +/// let mut settings = settings.clone(); +/// settings.select_module("testing", "testing.example"); +/// let plan = Arc::new(compile_auction_plan(&settings)?); +/// let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, &[builder])?; +/// assert!(registry.integration_runs("example")); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct IntegrationBuilder { id: &'static str, + source: &'static str, build: IntegrationBuilderFn, + validate: IntegrationValidateFn, + prepare_request: Option, + finalize_response: Option, + plan_registration: Option, + plan_validator: Option, + reads_auction_token: bool, + secret_settings: &'static [ModuleSecretSetting], + supplies_integration: bool, + demand: Option<&'static DemandImplementation>, + adserver: Option<&'static AdServerImplementation>, + module: Option<&'static str>, + section: Option<&'static str>, +} + +/// The build function of a builder that supplies no page integration. +fn no_registration( + _settings: &Settings, +) -> Result, Report> { + Ok(None) +} + +/// The validate function of a builder that supplies no page integration. +fn nothing_to_validate(_settings: &Settings) -> Result> { + Ok(false) +} + +impl IntegrationBuilder { + /// Creates a builder for the integration `id`, attributed to `source` + /// (a crate or package name used in duplicate-id errors). + #[must_use] + pub const fn new( + id: &'static str, + source: &'static str, + build: IntegrationBuilderFn, + validate: IntegrationValidateFn, + ) -> Self { + Self { + id, + source, + build, + validate, + prepare_request: None, + finalize_response: None, + plan_registration: None, + plan_validator: None, + reads_auction_token: false, + secret_settings: &[], + supplies_integration: true, + demand: None, + adserver: None, + module: None, + section: None, + } + } + + /// Creates a builder that supplies only implementations, such as a demand + /// or ad server implementation, and no page integration. + /// + /// Its `id` still claims a place among builder ids, so two crates cannot + /// register under one id, but it is not an integration a deployment can + /// name. + #[must_use] + pub const fn implementations(id: &'static str, source: &'static str) -> Self { + Self { + id, + source, + build: no_registration, + validate: nothing_to_validate, + prepare_request: None, + finalize_response: None, + plan_registration: None, + plan_validator: None, + reads_auction_token: false, + secret_settings: &[], + supplies_integration: false, + demand: None, + adserver: None, + module: None, + section: None, + } + } + + /// Registers a demand implementation, which `[demand] modules` or an + /// `implementation` line can name. + #[must_use] + pub const fn with_demand(mut self, demand: &'static DemandImplementation) -> Self { + self.demand = Some(demand); + self + } + + /// Registers an ad server implementation, which `[ad-server] module` or + /// an `implementation` line can name. + #[must_use] + pub const fn with_adserver(mut self, adserver: &'static AdServerImplementation) -> Self { + self.adserver = Some(adserver); + self + } + + /// Whether this builder supplies a page integration, as opposed to only + /// implementations. + #[must_use] + pub const fn supplies_integration(&self) -> bool { + self.supplies_integration + } + + /// The demand implementation this builder registers, when it registers + /// one. + #[must_use] + pub const fn demand(&self) -> Option<&'static DemandImplementation> { + self.demand + } + + /// The ad server implementation this builder registers, when it registers + /// one. + #[must_use] + pub const fn adserver(&self) -> Option<&'static AdServerImplementation> { + self.adserver + } + + /// Attaches a request preparation function that runs before routing on + /// every request, whether or not the integration runs. + #[must_use] + pub const fn with_request_preparer(mut self, prepare: IntegrationPrepareRequestFn) -> Self { + self.prepare_request = Some(prepare); + self + } + + /// Attaches a function that finishes the response the page path returns, + /// from what the module's request hooks left for the request. + #[must_use] + pub const fn with_response_finalizer( + mut self, + finalize: IntegrationFinalizeResponseFn, + ) -> Self { + self.finalize_response = Some(finalize); + self + } + + /// Registers the module from the compiled auction plan as well as the + /// settings, for a module whose page support follows what the plan + /// selects. + /// + /// The function runs whether or not a section selects the module, and + /// what it registers has its hooks run ahead of the modules the sections + /// select. + #[must_use] + pub const fn with_plan_registration(mut self, register: IntegrationPlanRegistrationFn) -> Self { + self.plan_registration = Some(register); + self + } + + /// Attaches a check of the module's configuration against the compiled + /// auction plan. + #[must_use] + pub const fn with_plan_validator(mut self, validate: IntegrationPlanValidateFn) -> Self { + self.plan_validator = Some(validate); + self + } + + /// Declares that the module's browser script reads the token an auction + /// publishes with its winning bids. + /// + /// A token is made for each auction when a section selects such a + /// module, and none is made in a deployment where nothing reads it. + #[must_use] + pub const fn with_auction_token(mut self) -> Self { + self.reads_auction_token = true; + self + } + + /// Whether the module's browser script reads the auction token. + #[must_use] + pub const fn reads_auction_token(&self) -> bool { + self.reads_auction_token + } + + /// Declares the settings in the module's own table that hold the name of + /// a secret, so each is looked up as the settings load and checked when + /// a deployment is validated. + #[must_use] + pub const fn with_secret_settings(mut self, secrets: &'static [ModuleSecretSetting]) -> Self { + self.secret_settings = secrets; + self + } + + /// The settings in the module's own table that hold the name of a + /// secret. + #[must_use] + pub const fn secret_settings(&self) -> &'static [ModuleSecretSetting] { + self.secret_settings + } + + /// Runs this builder when a section selects the module `name`, which is a + /// crate's path under `crates` with `.` between the parts, such as + /// `cmp.example`. An integration still in core holds the name its crate + /// will have as a constant. + #[must_use] + pub const fn with_module_name(mut self, name: &'static str) -> Self { + self.module = Some(name); + self + } + + /// The name a section selects this builder's module by, when it has one. + #[must_use] + pub const fn module_name(&self) -> Option<&'static str> { + self.module + } + + /// Names the section that selects this module, for one selected outside + /// the section of its type, such as a module of core's own in a section + /// of core's. + #[must_use] + pub const fn selected_in(mut self, section: &'static str) -> Self { + self.section = Some(section); + self + } + + /// The section that selects this builder's module: the one + /// [`selected_in`](Self::selected_in) named, or else its type's. + #[must_use] + pub fn section(&self) -> Option<&'static str> { + self.section.or_else(|| { + self.module + .and_then(|name| name.split_once('.').map(|(folder, _)| folder)) + }) + } + + /// The integration id this builder produces. + #[must_use] + pub const fn id(&self) -> &'static str { + self.id + } + + /// The source label used in diagnostics. + #[must_use] + pub const fn source(&self) -> &'static str { + self.source + } + + /// Builds the registration, or `None` when the settings give the + /// integration nothing to register. + /// + /// # Errors + /// + /// Returns an error when the integration runs with invalid configuration. + pub(crate) fn build( + &self, + settings: &Settings, + ) -> Result, Report> { + (self.build)(settings) + } + + /// Validates the integration's configuration for deployment and reports + /// whether a section selects the integration's module. + /// + /// # Errors + /// + /// Returns an error when the configuration cannot be parsed or fails + /// validation. + pub(crate) fn validate(&self, settings: &Settings) -> Result> { + (self.validate)(settings) + } + + /// The request preparation function, when one is attached. + pub(crate) fn prepare_request(&self) -> Option { + self.prepare_request + } + + /// The response finalizer, when one is attached. + pub(crate) fn finalize_response(&self) -> Option { + self.finalize_response + } + + /// The registration from the auction plan, when one is attached. + pub(crate) fn plan_registration(&self) -> Option { + self.plan_registration + } + + /// The check against the auction plan, when one is attached. + pub(crate) fn plan_validator(&self) -> Option { + self.plan_validator + } } +/// The built-in integrations, in hook order. +const BUILT_IN_BUILDERS: &[IntegrationBuilder] = &[ + // This must remain the first module a section selects: attribute + // rewriters chain replacements and short-circuit removals. + js_asset_proxy::BUILDER, + // A stand-in for an integration that streams, which core's own tests + // select where they need one. + #[cfg(test)] + registry_test_support::payload_fixture::BUILDER, + // A stand-in for an integration that tags a page, for the same tests. + #[cfg(test)] + registry_test_support::tag_fixture::BUILDER, + // A stand-in for an integration that acts on one request, for the same + // tests. + #[cfg(test)] + registry_test_support::request_fixture::BUILDER, + // A stand-in for an integration whose browser module loads deferred, for + // the same tests. + #[cfg(test)] + registry_test_support::deferred_fixture::BUILDER, + // A stand-in for the plainest demand implementation there can be, which + // core's own tests name where they need a source. + #[cfg(test)] + IntegrationBuilder::implementations( + crate::auction::test_support::plain_fixture::MODULE, + CORE_SOURCE, + ) + .with_demand(&crate::auction::test_support::plain_fixture::DEMAND), + // A stand-in demand implementation, which core's own tests name where + // they need one that departs from the plain one. + #[cfg(test)] + IntegrationBuilder::implementations( + crate::auction::test_support::demand_fixture::MODULE, + CORE_SOURCE, + ) + .with_demand(&crate::auction::test_support::demand_fixture::DEMAND), + // A stand-in ad server, which core's own tests select where they need + // one. + #[cfg(test)] + IntegrationBuilder::implementations( + crate::auction::test_support::adserver_fixture::MODULE, + CORE_SOURCE, + ) + .with_adserver(&crate::auction::test_support::adserver_fixture::ADSERVER), +]; + +/// The built-in integration builders, in hook order. pub(crate) fn builders() -> &'static [IntegrationBuilder] { - &[ - // This must remain first: attribute rewriters chain replacements and short-circuit removals. - IntegrationBuilder { - id: js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID, - build: js_asset_proxy::register, - }, - IntegrationBuilder { - id: "testlight", - build: testlight::register, - }, - IntegrationBuilder { - id: "nextjs", - build: nextjs::register, - }, - IntegrationBuilder { - id: "permutive", - build: permutive::register, - }, - IntegrationBuilder { - id: "lockr", - build: lockr::register, - }, - IntegrationBuilder { - id: "didomi", - build: didomi::register, - }, - IntegrationBuilder { - id: "sourcepoint", - build: sourcepoint::register, - }, - IntegrationBuilder { - id: "osano", - build: osano::register, - }, - IntegrationBuilder { - id: "google_tag_manager", - build: google_tag_manager::register, - }, - IntegrationBuilder { - id: "datadome", - build: datadome::register, - }, - IntegrationBuilder { - id: "gpt", - build: gpt::register, - }, - IntegrationBuilder { - id: "gpt_diagnostics", - build: gpt_diagnostics::register, - }, - ] + BUILT_IN_BUILDERS } -#[cfg(test)] -pub(crate) fn registered_builder_ids() -> impl Iterator { - builders().iter().map(|builder| builder.id) +/// Every builder the registry will consider: the built-in set followed by +/// `extra`, in that order, so hook order for the built-ins never changes. +pub(crate) fn all_builders( + extra: &[IntegrationBuilder], +) -> impl Iterator + '_ { + builders().iter().copied().chain(extra.iter().copied()) } diff --git a/crates/trusted-server-core/src/integrations/osano.rs b/crates/trusted-server-core/src/integrations/osano.rs deleted file mode 100644 index 9fc3aa66c1..0000000000 --- a/crates/trusted-server-core/src/integrations/osano.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Osano integration for client-side consent mirroring. -//! -//! The Rust side of this integration intentionally only provides explicit -//! enablement for the `tsjs-osano` browser module. Osano consent extraction runs -//! in JavaScript because the relevant CMP APIs (`__uspapi`, `__gpp`, and -//! `__tcfapi`) are browser-only. - -use error_stack::Report; -use serde::Deserialize; -use validator::Validate; - -use crate::error::TrustedServerError; -use crate::settings::{IntegrationConfig, Settings}; - -use super::IntegrationRegistration; - -const OSANO_INTEGRATION_ID: &str = "osano"; - -/// Configuration for the Osano consent mirror integration. -#[derive(Debug, Clone, Deserialize, Validate)] -#[serde(deny_unknown_fields)] -pub struct OsanoConfig { - /// Whether the Osano browser consent mirror is enabled. - #[serde(default)] - pub enabled: bool, -} - -impl IntegrationConfig for OsanoConfig { - fn is_enabled(&self) -> bool { - self.enabled - } -} - -/// Register the Osano JS integration when enabled. -/// -/// # Errors -/// -/// Returns an error when the Osano integration configuration cannot be parsed or -/// fails validation. -pub fn register( - settings: &Settings, -) -> Result, Report> { - let Some(_config) = settings.integration_config::(OSANO_INTEGRATION_ID)? else { - return Ok(None); - }; - - Ok(Some( - IntegrationRegistration::builder(OSANO_INTEGRATION_ID).build(), - )) -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::{OsanoConfig, register}; - use crate::test_support::tests::create_test_settings; - - #[test] - fn register_returns_none_when_disabled() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config("osano", &json!({ "enabled": false })) - .expect("should insert osano config"); - - let registration = register(&settings).expect("should parse disabled osano config"); - - assert!( - registration.is_none(), - "disabled Osano integration should not register" - ); - } - - #[test] - fn register_returns_js_module_registration_when_enabled() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config("osano", &json!({ "enabled": true })) - .expect("should insert osano config"); - - let registration = register(&settings) - .expect("should parse enabled osano config") - .expect("enabled Osano integration should register"); - - assert_eq!(registration.integration_id, "osano"); - assert!( - registration.proxies.is_empty(), - "Osano v1 should not register Rust proxy routes" - ); - assert!( - registration.head_injectors.is_empty(), - "Osano v1 should not inject HTML from Rust" - ); - } - - #[test] - fn config_rejects_unknown_fields() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config("osano", &json!({ "enabled": true, "typo": true })) - .expect("should insert osano config"); - - let err = settings - .integration_config::("osano") - .expect_err("should reject unknown Osano config fields"); - let error_text = format!("{err:?}"); - - assert!( - error_text.contains("typo") || error_text.contains("unknown field"), - "error should mention the unknown field: {err:?}" - ); - } -} diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index cfd042fab8..a618dafddc 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -7,15 +7,18 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::{Method, Request, Response}; use matchit::Router; +use sha2::{Digest as _, Sha256}; use crate::auction::AuctionPlan; use crate::constants::HEADER_X_TS_EC; use crate::ec::EcContext; +use crate::ec::device::DeviceModule; use crate::ec::kv::KvIdentityGraph; +use crate::ec::module::{EcModuleSelection, EdgeCookieModule}; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; -use crate::platform::RuntimeServices; +use crate::platform::{DisabledGeo, PlatformGeo, RuntimeServices}; use crate::settings::Settings; use crate::streaming_processor::StreamProcessor; @@ -191,6 +194,104 @@ impl IntegrationDocumentState { } } +/// Values a module's request hooks leave for its own page hooks, for one +/// request. +/// +/// A request preparer or a request filter leaves a value under its +/// integration id with [`IntegrationRequestState::insert`]. When the request +/// produces an HTML document for that one reader, every value is copied into +/// the document's [`IntegrationDocumentState`] before parsing starts, so the +/// module's head injector, rewriters and stream processors read it there, and +/// the same values are handed to the module's response finalizer. +/// +/// A request that carries any value keeps to the origin path. Its document is +/// never read from a shared template and never stored as one, and an HTML +/// response with a body is sent `private, no-store`. A document built to be +/// shared starts with none of these values. +#[derive(Clone, Default)] +pub struct IntegrationRequestState { + values: IntegrationDocumentStateMap, +} + +impl std::fmt::Debug for IntegrationRequestState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IntegrationRequestState") + .field("keys", &self.values.keys().collect::>()) + .finish() + } +} + +impl IntegrationRequestState { + /// Leaves `value` on `request` for the page hooks of `integration_id`, + /// in place of a value of the same type left earlier. + pub fn insert(request: &mut Request, integration_id: &'static str, value: T) + where + T: Any + Send + Sync + 'static, + { + let mut state = request + .extensions_mut() + .remove::() + .unwrap_or_default(); + state.set(integration_id, value); + request.extensions_mut().insert(state); + } + + /// Holds `value` for the page hooks of `integration_id`, in place of a + /// value of the same type held earlier. + pub fn set(&mut self, integration_id: &'static str, value: T) + where + T: Any + Send + Sync + 'static, + { + self.values + .insert((integration_id, TypeId::of::()), Arc::new(value)); + } + + /// The values left on `request`, which is none for a request no module + /// left one on. + #[must_use] + pub fn of(request: &Request) -> Self { + request + .extensions() + .get::() + .cloned() + .unwrap_or_default() + } + + /// The value of type `T` left for `integration_id`. + #[must_use] + pub fn get(&self, integration_id: &'static str) -> Option> + where + T: Any + Send + Sync + 'static, + { + let value = self.values.get(&(integration_id, TypeId::of::()))?; + Arc::clone(value).downcast::().ok() + } + + /// Whether no module left a value. + #[must_use] + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// Copies every value into the state of a document that is starting. + /// + /// # Panics + /// + /// Panics if the document state's lock is poisoned. + pub(crate) fn seed(&self, document: &IntegrationDocumentState) { + if self.values.is_empty() { + return; + } + let mut guard = document + .inner + .lock() + .expect("should lock integration document state"); + for (key, value) in &self.values { + guard.insert(*key, Arc::clone(value)); + } + } +} + /// Per-document buffer for script text fragments split across chunks. /// /// `lol_html` can deliver one text node as several chunks, so a rewriter that @@ -621,23 +722,69 @@ pub trait IntegrationHeadInjector: Send + Sync { /// Return HTML snippets to insert at the start of ``. fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; + /// Return HTML snippets to insert straight after the main script bundle + /// and before any deferred one, for a script that needs the bundle to + /// have run and has to run before the page's own scripts. + fn after_bundle_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + Vec::new() + } + /// Return attributes to add to the publisher TSJS bundle tag. fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { Vec::new() } } +/// A browser module a registration carries, for a module built outside +/// `trusted-server-js`. The crate embeds its built IIFE with `include_str!` +/// and states its SHA-256 as a literal next to it; the registry verifies the +/// two agree when it is built, and the served `?v=` hash is derived from it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CarriedJsModule { + /// The built IIFE. + pub source: &'static str, + /// SHA-256 of `source`, hex encoded, lower case. + pub sha256: &'static str, +} + /// Registration payload returned by integration builders. pub struct IntegrationRegistration { pub integration_id: &'static str, pub js_deferred: bool, pub js_disabled: bool, + /// Browser module carried by the registration, when the module is not + /// compiled into `trusted-server-js`. + pub js_module: Option, + /// Serve the module only on its own `/static/tsjs=tsjs-.min.js` path, + /// never in the unified bundle and never as a deferred tag; the + /// integration injects the tag itself when it decides to. + pub js_standalone: bool, pub proxies: Vec>, pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, pub html_stream_processors: Vec>, pub head_injectors: Vec>, pub request_filters: Vec>, + /// Geo module this registration supplies, with the name `[geo] module` + /// selects it by. + /// + /// Declaring one does not make it active, because the module is only asked + /// to resolve location when `[geo] module` names it. + pub geo_module: Option<(&'static str, Arc)>, + /// Edge Cookie module this registration supplies, with the name + /// `[ec] module` selects it by. + /// + /// Declaring one does not make it active, because the module is only asked + /// to create an identifier when `[ec] module` names it. This is the same + /// route geo takes, so identity is not a second extension mechanism + /// sitting beside the integration system. + pub ec_module: Option<(&'static str, Arc)>, + /// Device module this registration supplies, with the name + /// `[device] module` selects it by. + /// + /// Declaring one does not make it active, because the module is only asked + /// to classify a request when `[device] module` names it. + pub device_module: Option<(&'static str, Arc)>, } impl IntegrationRegistration { @@ -658,12 +805,17 @@ impl IntegrationRegistrationBuilder { integration_id, js_deferred: false, js_disabled: false, + js_module: None, + js_standalone: false, proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), + geo_module: None, + ec_module: None, + device_module: None, }, } } @@ -710,11 +862,53 @@ impl IntegrationRegistrationBuilder { self } + /// Declare the geo module this registration supplies, under its `name`. + /// + /// The name is the path under `crates` of the crate the module lives in, + /// as [`crate::module_name!`] gives it, so a module from + /// `crates/geo/example` is selected by `[geo] module = "example"`. One + /// registration can therefore supply a module of each type, each under + /// the name of its own crate. The module only resolves location when + /// `[geo] module` names it, and a module the selector does not choose is + /// logged as a warning when the registry is built. + #[must_use] + pub fn with_geo_module(mut self, name: &'static str, module: Arc) -> Self { + self.registration.geo_module = Some((name, module)); + self + } + + /// Declare the Edge Cookie module this registration supplies, under its + /// `name`, which is also what the module's + /// [`id`](EdgeCookieModule::id) returns. + /// + /// The module only creates identifiers when `[ec] module` names it, and + /// a module the selector does not choose is logged as a warning when the + /// registry is built, the same as geo. + #[must_use] + pub fn with_ec_module(mut self, name: &'static str, module: Arc) -> Self { + self.registration.ec_module = Some((name, module)); + self + } + + /// Declare the device module this registration supplies, under its + /// `name`. + /// + /// The module only classifies requests when `[device] module` names it, + /// and a module the selector does not choose is logged as a warning when + /// the registry is built, the same as geo. + #[must_use] + pub fn with_device_module(mut self, name: &'static str, module: Arc) -> Self { + self.registration.device_module = Some((name, module)); + self + } + /// Mark this integration's JS module for deferred loading via /// `")] + } + + fn after_bundle_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + if !Self::marked(ctx) { + return Vec::new(); + } + let module = JsModulePart { + id: ID, + source: JS, + sha256: JS_SHA256, + }; + vec![format!( + "", + crate::tsjs::tsjs_single_module_script_src(&module) + )] + } + } + + fn register( + settings: &Settings, + ) -> Result, Report> { + let Some(_config) = settings.module_config::(MODULE)? else { + return Ok(None); + }; + Ok(Some( + IntegrationRegistration::builder(ID) + .with_js_module(CarriedJsModule { + source: JS, + sha256: JS_SHA256, + }) + .with_standalone_js() + .with_head_injector(Arc::new(Head)) + .with_request_filter(Arc::new(Filter)) + .build(), + )) + } + + fn validate(settings: &Settings) -> Result> { + settings + .module_config::(MODULE) + .map(|config| config.is_some()) + } + } + + /// A stand-in for an integration whose browser module loads deferred, for + /// core's own tests of how modules are divided between the bundle and + /// deferred tags, and of how a module's settings reach a page template. + /// + /// Selected, it carries a deferred browser module and writes its two + /// settings into ``. + pub(crate) mod deferred_fixture { + use std::sync::Arc; + + use error_stack::Report; + + use crate::error::TrustedServerError; + use crate::integrations::registry::{ + CarriedJsModule, IntegrationHeadInjector, IntegrationHtmlContext, + IntegrationRegistration, + }; + use crate::integrations::{CORE_SOURCE, IntegrationBuilder}; + use crate::settings::Settings; + + /// The integration id the stand-in registers under. + pub(crate) const ID: &str = "deferred_fixture"; + /// The name a test's settings select the stand-in by, in `[testing]`. + pub(crate) const MODULE: &str = "testing.deferred-fixture"; + /// The file of the stand-in's deferred module. + pub(crate) const MODULE_FILE: &str = "tsjs-deferred_fixture.min.js"; + + const JS: &str = "(function(){window.__ts_deferred_fixture_loaded=1;})();"; + // SHA-256 of JS, hex. The registry refuses a literal that is not. + const JS_SHA256: &str = "3947bc5e93ffb5d057f0d500d20ee25d53612b6c8b59cd3f9512c1b64b45f9f6"; + + /// The builder core's test build lists beside its own. + pub(crate) const BUILDER: IntegrationBuilder = + IntegrationBuilder::new(ID, CORE_SOURCE, register, validate).with_module_name(MODULE); + + #[derive(Debug, serde::Deserialize, validator::Validate)] + #[serde(deny_unknown_fields)] + struct FixtureSettings { + #[serde(default)] + label: String, + #[serde(default)] + timeout_ms: u32, + } + + impl crate::settings::IntegrationConfig for FixtureSettings {} + + struct Head { + label: String, + timeout_ms: u32, + } + + impl IntegrationHeadInjector for Head { + fn integration_id(&self) -> &'static str { + ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + let config = serde_json::json!({ + "label": self.label, + "timeoutMs": self.timeout_ms, + }); + vec![format!( + "" + )] + } + } + + fn register( + settings: &Settings, + ) -> Result, Report> { + let Some(config) = settings.module_config::(MODULE)? else { + return Ok(None); + }; + Ok(Some( + IntegrationRegistration::builder(ID) + .with_js_module(CarriedJsModule { + source: JS, + sha256: JS_SHA256, + }) + .with_deferred_js() + .with_head_injector(Arc::new(Head { + label: config.label, + timeout_ms: config.timeout_ms, + })) + .build(), + )) + } + + fn validate(settings: &Settings) -> Result> { + settings + .module_config::(MODULE) + .map(|config| config.is_some()) + } + } + + /// A stand-in for an integration that tags a page, for core's own tests + /// of the HTML processor and the JavaScript asset proxy. + /// + /// Selected, it inserts one script at the start of `` and rewrites + /// the address of its own script to a first-party path. With + /// `mark_bundle` set it also asks for an attribute on the publisher + /// bundle tag. + pub(crate) mod tag_fixture { + use std::sync::Arc; + + use error_stack::Report; + + use crate::error::TrustedServerError; + use crate::integrations::registry::{ + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationHeadInjector, IntegrationHtmlContext, IntegrationRegistration, + }; + use crate::integrations::{CORE_SOURCE, IntegrationBuilder}; + use crate::settings::Settings; + + /// The integration id the stand-in registers under. + pub(crate) const ID: &str = "tag_fixture"; + /// The name a test's settings select the stand-in by, in `[testing]`. + pub(crate) const MODULE: &str = "testing.tag-fixture"; + /// The script the stand-in's vendor would serve. + pub(crate) const SCRIPT_URL: &str = "https://cdn.tag-fixture.example/sdk.js"; + /// The first-party path the stand-in rewrites that script to. + pub(crate) const FIRST_PARTY_SCRIPT: &str = "/integrations/tag_fixture/script"; + /// What the stand-in's head insert sets, so a test can find it. + pub(crate) const HEAD_FLAG: &str = "window.__ts_tag_fixture=true;"; + /// The attribute the stand-in asks for on the publisher bundle tag. + pub(crate) const BUNDLE_ATTRIBUTE: &str = "data-ts-tag-fixture"; + + /// The builder core's test build lists beside its own. + pub(crate) const BUILDER: IntegrationBuilder = + IntegrationBuilder::new(ID, CORE_SOURCE, register, validate).with_module_name(MODULE); + + #[derive(Debug, serde::Deserialize, validator::Validate)] + #[serde(deny_unknown_fields)] + struct FixtureSettings { + #[serde(default)] + mark_bundle: bool, + } + + impl crate::settings::IntegrationConfig for FixtureSettings {} + + struct Tag { + mark_bundle: bool, + } + + impl IntegrationHeadInjector for Tag { + fn integration_id(&self) -> &'static str { + ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + vec![format!("")] + } + + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + if self.mark_bundle { + vec![(BUNDLE_ATTRIBUTE, "true")] + } else { + Vec::new() + } + } + } + + impl IntegrationAttributeRewriter for Tag { + fn integration_id(&self) -> &'static str { + ID + } + + fn handles_attribute(&self, attribute: &str) -> bool { + attribute == "src" + } + + fn rewrite( + &self, + _attr_name: &str, + attr_value: &str, + _ctx: &IntegrationAttributeContext<'_>, + ) -> AttributeRewriteAction { + if attr_value == SCRIPT_URL { + AttributeRewriteAction::Replace(FIRST_PARTY_SCRIPT.to_owned()) + } else { + AttributeRewriteAction::Keep + } + } + } + + fn register( + settings: &Settings, + ) -> Result, Report> { + let Some(config) = settings.module_config::(MODULE)? else { + return Ok(None); + }; + let tag = Arc::new(Tag { + mark_bundle: config.mark_bundle, + }); + Ok(Some( + IntegrationRegistration::builder(ID) + .without_js() + .with_attribute_rewriter(tag.clone()) + .with_head_injector(tag) + .build(), + )) + } + + fn validate(settings: &Settings) -> Result> { + settings + .module_config::(MODULE) + .map(|config| config.is_some()) + } + } + + /// A stand-in for an integration that rewrites script payloads in two + /// passes, for core's own tests of the page pipeline. + /// + /// The script rewriter swaps the payload of each `fixture_payload("...")` + /// call for a placeholder that carries a namespace made per document, and + /// keeps the payload in the document state. The stream processor swaps + /// each placeholder back for its payload with the origin host rewritten. + /// A payload pushed with `fixture_payload_open` leaves its group + /// unresolved, and the processor holds its output from that placeholder + /// on until a `fixture_payload_close` arrives. + pub(crate) mod payload_fixture { + use std::io; + use std::sync::{Arc, Mutex, PoisonError}; + + use error_stack::Report; + + use crate::error::TrustedServerError; + use crate::integrations::registry::{ + IntegrationHtmlStreamContext, IntegrationHtmlStreamProcessorFactory, + IntegrationRegistration, IntegrationScriptContext, IntegrationScriptRewriter, + ScriptRewriteAction, ScriptTextAccumulator, + }; + use crate::integrations::{CORE_SOURCE, IntegrationBuilder}; + use crate::settings::Settings; + use crate::streaming_processor::StreamProcessor; + + /// The integration id the stand-in registers under. + pub(crate) const ID: &str = "payload_fixture"; + /// The name a test's settings select the stand-in by, in `[testing]`. + pub(crate) const MODULE: &str = "testing.payload-fixture"; + /// What every placeholder starts with, so a test can check that none + /// reaches a reader. + pub(crate) const PLACEHOLDER_PREFIX: &str = "__ts_fixture_"; + const PLACEHOLDER_END: &str = "__"; + + /// The builder core's test build lists beside its own. + pub(crate) const BUILDER: IntegrationBuilder = + IntegrationBuilder::new(ID, CORE_SOURCE, register, validate).with_module_name(MODULE); + + struct Payload { + placeholder: String, + original: String, + /// Whether the group this payload opened is still waiting for its + /// close. + unresolved: bool, + } + + /// What one document's script rewriter has captured so far. + struct Captured { + namespace: String, + payloads: Vec, + } + + impl Default for Captured { + fn default() -> Self { + Self { + namespace: uuid::Uuid::new_v4().simple().to_string(), + payloads: Vec::new(), + } + } + } + + fn captured( + state: &crate::integrations::registry::IntegrationDocumentState, + ) -> Arc> { + state.get_or_insert_with(ID, || Mutex::new(Captured::default())) + } + + struct ScriptRewriter; + + impl ScriptRewriter { + /// Swaps the payload of a whole script for a placeholder, or + /// leaves a script that pushes no payload as it is. + fn rewrite_whole(script: &str, ctx: &IntegrationScriptContext<'_>) -> Option { + let (call, opens, closes) = [ + ("fixture_payload_open(\"", true, false), + ("fixture_payload_close(\"", false, true), + ("fixture_payload(\"", false, false), + ] + .into_iter() + .find(|(call, _, _)| script.contains(*call))?; + let start = script.find(call)? + call.len(); + let end = script.rfind("\")")?; + if end < start { + return None; + } + + let shared = captured(ctx.document_state); + let mut captured = shared.lock().unwrap_or_else(PoisonError::into_inner); + let placeholder = format!( + "{PLACEHOLDER_PREFIX}{}_{}{PLACEHOLDER_END}", + captured.namespace, + captured.payloads.len() + ); + if closes { + for payload in &mut captured.payloads { + payload.unresolved = false; + } + } + captured.payloads.push(Payload { + placeholder: placeholder.clone(), + original: script[start..end].to_owned(), + unresolved: opens, + }); + + let mut rewritten = script.to_owned(); + rewritten.replace_range(start..end, &placeholder); + Some(rewritten) + } + } + + impl IntegrationScriptRewriter for ScriptRewriter { + fn integration_id(&self) -> &'static str { + ID + } + + fn selector(&self) -> &'static str { + "script" + } + + fn rewrite( + &self, + content: &str, + ctx: &IntegrationScriptContext<'_>, + ) -> ScriptRewriteAction { + let accumulator = ctx + .document_state + .get_or_insert_with(ID, ScriptTextAccumulator::default); + let mut buffer = accumulator.buffer(); + let claimed = !buffer.is_empty() || content.contains("fixture_payload"); + if !claimed { + return ScriptRewriteAction::Keep; + } + buffer.push_str(content); + if !ctx.is_last_in_text_node { + return ScriptRewriteAction::RemoveNode; + } + let script = std::mem::take(&mut *buffer); + let rewritten = Self::rewrite_whole(&script, ctx).unwrap_or(script); + ScriptRewriteAction::replace(rewritten) + } + } + + struct StreamFactory; + + impl IntegrationHtmlStreamProcessorFactory for StreamFactory { + fn integration_id(&self) -> &'static str { + ID + } + + fn create(&self, context: IntegrationHtmlStreamContext) -> Box { + Box::new(Processor { + context, + held: Vec::new(), + }) + } + } + + struct Processor { + context: IntegrationHtmlStreamContext, + /// Output not yet released, because it ends inside a placeholder + /// or starts at one whose group is unresolved. + held: Vec, + } + + fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option { + haystack + .get(from..)? + .windows(needle.len()) + .position(|window| window == needle) + .map(|at| at + from) + } + + impl StreamProcessor for Processor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> Result, io::Error> { + self.held.extend_from_slice(chunk); + let shared = captured(&self.context.document_state); + let captured = shared.lock().unwrap_or_else(PoisonError::into_inner); + let prefix = PLACEHOLDER_PREFIX.as_bytes(); + let mut out = Vec::with_capacity(self.held.len()); + let mut at = 0; + + while let Some(start) = find(&self.held, prefix, at) { + let Some(end) = + find(&self.held, PLACEHOLDER_END.as_bytes(), start + prefix.len()) + else { + // The placeholder runs past this chunk. + out.extend_from_slice(&self.held[at..start]); + at = start; + break; + }; + let end = end + PLACEHOLDER_END.len(); + let found = std::str::from_utf8(&self.held[start..end]) + .ok() + .and_then(|text| { + captured + .payloads + .iter() + .find(|payload| payload.placeholder == text) + }); + match found { + Some(payload) if payload.unresolved && !is_last => { + out.extend_from_slice(&self.held[at..start]); + at = start; + break; + } + Some(payload) => { + out.extend_from_slice(&self.held[at..start]); + out.extend_from_slice( + payload + .original + .replace(&self.context.origin_host, &self.context.request_host) + .as_bytes(), + ); + at = end; + } + None => { + out.extend_from_slice(&self.held[at..end]); + at = end; + } + } + } + + let stopped_at_placeholder = find(&self.held, prefix, at) == Some(at); + if is_last { + out.extend_from_slice(&self.held[at..]); + self.held.clear(); + } else if stopped_at_placeholder { + self.held.drain(..at); + } else { + // Keep back a tail that could be the start of a + // placeholder split across chunks. + let rest = &self.held[at..]; + let keep = (1..prefix.len().min(rest.len() + 1)) + .rev() + .find(|len| rest.ends_with(&prefix[..*len])) + .unwrap_or(0); + out.extend_from_slice(&rest[..rest.len() - keep]); + let tail = rest[rest.len() - keep..].to_vec(); + self.held = tail; + } + Ok(out) + } + } + + fn register( + settings: &Settings, + ) -> Result, Report> { + if settings.module_config::(MODULE)?.is_none() { + return Ok(None); + } + Ok(Some( + IntegrationRegistration::builder(ID) + .with_script_rewriter(Arc::new(ScriptRewriter)) + .with_html_stream_processor(Arc::new(StreamFactory)) + .build(), + )) + } + + /// The stand-in takes no settings, and refuses one it does not know + /// as any module does. + #[derive(Debug, serde::Deserialize, validator::Validate)] + #[serde(deny_unknown_fields)] + struct FixtureSettings {} + + impl crate::settings::IntegrationConfig for FixtureSettings {} + + fn validate(settings: &Settings) -> Result> { + settings + .module_config::(MODULE) + .map(|config| config.is_some()) + } + } +} + #[cfg(test)] mod tests { + use super::test_support::{ + PROBE_JS, PROBE_JS_SHA256, carried_probe_registration, probe_registration, validate_nothing, + }; use super::*; use crate::constants::COOKIE_TS_EC; use crate::permissions::{Permission, PermissionSet, PermissionState}; @@ -1576,7 +3351,7 @@ mod tests { ) -> Result> { input.request.extensions_mut().insert(RequestAnnotation); Ok(RequestFilterDecision::Continue(RequestFilterEffects { - request_headers: vec![HeaderMutation::set("x-datadome-isbot", "1")], + request_headers: vec![HeaderMutation::set("x-probe-isbot", "1")], response_headers: vec![HeaderMutation::set("x-dd-b", "allowed")], })) } @@ -1813,10 +3588,10 @@ mod tests { assert_eq!( req.headers() - .get("x-datadome-isbot") + .get("x-probe-isbot") .and_then(|value| value.to_str().ok()), Some("1"), - "should apply DataDome-style request enrichment before routing" + "should apply a filter's request enrichment before routing" ); assert!( req.extensions().get::().is_some(), @@ -2339,26 +4114,14 @@ mod tests { } #[test] - fn js_module_ids_defer_prebid_and_include_core_js_only_modules() { - let settings = crate::test_support::tests::create_test_settings(); - let mut settings_with_prebid = settings; - settings_with_prebid - .integrations - .insert_config( - "prebid", - &serde_json::json!({ - "enabled": true, - "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", - "timeout_ms": 1000, - "debug": false - }), - ) - .expect("should insert prebid config"); + fn js_module_ids_defer_a_deferred_module_and_include_core_js_only_modules() { + let mut settings = crate::test_support::tests::create_test_settings(); + enable_deferred_module(&mut settings); let registry = IntegrationRegistry::with_plan( - &settings_with_prebid, + &settings, Arc::new( - crate::auction::compile_auction_plan(&settings_with_prebid) + crate::auction::compile_auction_plan(&settings) .expect("should compile auction plan"), ), ) @@ -2367,10 +4130,11 @@ mod tests { let all = registry.js_module_ids(); let immediate = registry.js_module_ids_immediate(); let deferred = registry.js_module_ids_deferred(); + let module = test_support::deferred_fixture::ID; assert!( - all.contains(&"prebid"), - "should include the prebid shim in embedded TSJS module IDs" + all.contains(&module), + "should include the deferred module in the module IDs" ); assert!( immediate.contains(&"creative"), @@ -2378,47 +4142,42 @@ mod tests { ); assert!( !immediate.contains(&"sourcepoint"), - "should not include Sourcepoint unless explicitly enabled" + "should not include Sourcepoint unless it is named" ); assert!( - !immediate.contains(&"prebid"), - "should not include prebid in immediate IDs" + !immediate.contains(&module), + "should not include the deferred module in immediate IDs" ); assert!( - deferred.contains(&"prebid"), - "should serve the prebid shim as a deferred module" + deferred.contains(&module), + "should serve the module as a deferred module" ); } #[test] - fn js_module_ids_skip_enabled_integrations_without_generated_js_module() { - let mut settings = crate::test_support::tests::create_test_settings(); - settings - .integrations - .insert_config("nextjs", &serde_json::json!({ "enabled": true })) - .expect("should insert nextjs config"); - - let registry = IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), + fn js_module_ids_skip_named_integrations_without_generated_js_module() { + let settings = settings_naming("testing.probe"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + probe_registration, + validate_nothing, ) - .expect("should create registry"); + .with_module_name("testing.probe")]; + + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should create registry"); let all = registry.js_module_ids(); assert!( - !all.contains(&"nextjs"), - "should not include enabled integrations without generated JS modules" + !all.contains(&"probe"), + "should not include named integrations without generated JS modules" ); let metadata = registry.registered_integrations(); assert!( - metadata - .iter() - .any(|integration| integration.id == "nextjs"), - "should still register enabled Rust-only integrations" + metadata.iter().any(|integration| integration.id == "probe"), + "should still register named Rust-only integrations" ); } @@ -2439,16 +4198,25 @@ mod tests { } #[test] - fn js_module_ids_include_explicitly_enabled_cmp_mirrors() { + fn js_module_ids_exclude_client_fixed_without_module() { + let registry = + IntegrationRegistry::new(&crate::test_support::tests::create_test_settings()) + .expect("should create registry"); + + assert!( + !registry + .js_module_ids_immediate() + .contains(&"ec_client_fixed"), + "the demo page script should not ship unless the `client_fixed` module is selected" + ); + } + + #[test] + fn js_module_ids_deferred_empty_when_prebid_is_not_named() { let mut settings = crate::test_support::tests::create_test_settings(); - settings - .integrations - .insert_config("sourcepoint", &serde_json::json!({ "enabled": true })) - .expect("should insert sourcepoint config"); - settings - .integrations - .insert_config("osano", &serde_json::json!({ "enabled": true })) - .expect("should insert osano config"); + // The shared fixture names prebid, and this asks what the registry + // serves when it does not. + settings.auction.modules.clear(); let registry = IntegrationRegistry::with_plan( &settings, @@ -2458,146 +4226,1537 @@ mod tests { ), ) .expect("should create registry"); - let immediate = registry.js_module_ids_immediate(); + let deferred = registry.js_module_ids_deferred(); assert!( - immediate.contains(&"sourcepoint"), - "should include Sourcepoint when explicitly enabled" + deferred.is_empty(), + "should have no deferred IDs when prebid is not named" ); + } + + #[test] + fn js_module_ids_split_is_exhaustive() { + let mut settings = crate::test_support::tests::create_test_settings(); + enable_deferred_module(&mut settings); + + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); + + let all = registry.js_module_ids(); assert!( - immediate.contains(&"osano"), - "should include Osano when explicitly enabled" + !registry.js_module_ids_deferred().is_empty(), + "should have a deferred module to divide from the bundle" ); + let mut recombined = registry.js_module_ids_immediate(); + recombined.extend(registry.js_module_ids_deferred()); + recombined.sort_unstable(); + + let mut all_sorted = all; + all_sorted.sort_unstable(); + + assert_eq!( + recombined, all_sorted, + "should reconstruct full module list from immediate + deferred" + ); + } + + /// Claims the id of the JavaScript asset proxy, which is core's own. + fn duplicate_core_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder( + crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID, + ) + .build(), + )) + } + + /// The shared fixture with the module `name` selected in its section, so a + /// test builder is built the way any integration an operator names is. + fn settings_naming(name: &str) -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + let section = name.split_once('.').map_or("testing", |(folder, _)| folder); + settings.select_module(section, name); + settings + } + + #[test] + fn with_registrations_adds_an_external_builder_after_the_built_ins() { + let settings = settings_naming("probe"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry with an external builder"); - let metadata = registry.registered_integrations(); assert!( - metadata.iter().any(|integration| integration.id == "osano"), - "should include JS-only Osano registration in metadata" + registry.integration_runs("probe"), + "should register the external integration" + ); + let ids = registry.registered_builder_ids(); + assert_eq!( + ids.last().copied(), + Some("probe"), + "should order the external builder after every built-in" ); } #[test] - fn js_module_ids_exclude_client_fixed_without_module() { - let registry = - IntegrationRegistry::new(&crate::test_support::tests::create_test_settings()) - .expect("should create registry"); + fn with_registrations_rejects_a_duplicate_integration_id_naming_both_sources() { + let settings = crate::test_support::tests::create_test_settings(); + let extra = [crate::integrations::IntegrationBuilder::new( + crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID, + "seam-probe", + duplicate_core_registration, + validate_nothing, + ) + .with_module_name("testing.duplicate")]; + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should reject a duplicate integration id"); + + let message = error.to_string(); assert!( - !registry - .js_module_ids_immediate() - .contains(&"ec_client_fixed"), - "the demo page script should not ship unless the `client_fixed` module is selected" + message.contains(crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID) + && message.contains("trusted-server-core") + && message.contains("seam-probe"), + "error should name the id and both sources: {message}" ); } + /// A builder that registers from the auction plan holds its id like any + /// other, so a second builder claiming it is refused, naming both sources, + /// whether or not the plan-backed integration runs. #[test] - fn js_module_ids_deferred_empty_when_prebid_disabled() { - let mut settings = crate::test_support::tests::create_test_settings(); - settings - .integrations - .insert_config( - "prebid", - &serde_json::json!({ - "enabled": false, - "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", - }), - ) - .expect("should update prebid config"); + fn with_registrations_rejects_a_second_builder_claiming_a_plan_backed_id() { + fn register_nothing( + _settings: &Settings, + ) -> Result, Report> { + Ok(None) + } + fn nothing_from_the_plan( + _settings: &Settings, + _plan: &crate::auction::plan::AuctionPlan, + ) -> Result, Report> { + Ok(None) + } - let registry = IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), + let settings = crate::test_support::tests::create_test_settings(); + let id = "plan_backed"; + let extra = [ + crate::integrations::IntegrationBuilder::new( + id, + "plan-backed-crate", + register_nothing, + validate_nothing, + ) + .with_plan_registration(nothing_from_the_plan), + crate::integrations::IntegrationBuilder::new( + id, + "seam-probe", + register_nothing, + validate_nothing, ), + ]; + + let Err(error) = IntegrationRegistry::with_registrations(&settings, &extra) else { + panic!("should refuse a second builder claiming `{id}`"); + }; + + let message = error.to_string(); + assert!( + message.contains(id) + && message.contains("plan-backed-crate") + && message.contains("seam-probe"), + "error should name `{id}` and both sources: {message}" + ); + } + + /// Selects core's stand-in, which registers a deferred browser module. + fn enable_deferred_module(settings: &mut Settings) { + settings.select_module("testing", test_support::deferred_fixture::MODULE); + } + + /// Selects core's stand-in, which registers a standalone browser module. + fn enable_standalone_module(settings: &mut Settings) { + settings.select_module("testing", test_support::request_fixture::MODULE); + } + + fn carried_probe_builder() -> crate::integrations::IntegrationBuilder { + crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + carried_probe_registration, + validate_nothing, ) - .expect("should create registry"); + .with_module_name("testing.probe") + } - let deferred = registry.js_module_ids_deferred(); + const ZERO_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + fn lying_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .with_js_module(CarriedJsModule { + source: PROBE_JS, + sha256: ZERO_SHA256, + }) + .build(), + )) + } + + fn disabled_carried_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .with_js_module(CarriedJsModule { + source: PROBE_JS, + sha256: PROBE_JS_SHA256, + }) + .without_js() + .build(), + )) + } + + #[test] + fn probe_js_hash_literal_matches_its_source() { + assert_eq!( + hex::encode(Sha256::digest(PROBE_JS.as_bytes())), + PROBE_JS_SHA256, + "should keep the PROBE_JS_SHA256 literal equal to the hash of PROBE_JS" + ); + } + + #[test] + fn a_carried_js_module_is_served_in_the_immediate_parts() { + let settings = settings_naming("probe"); + let extra = [carried_probe_builder()]; + + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry with a carried module"); + + let parts = registry.js_parts_immediate(); + assert_eq!( + parts.first().map(|part| part.id), + Some("core"), + "should put core first in the immediate parts" + ); + let probe = parts + .iter() + .find(|part| part.id == "probe") + .expect("should include the carried probe module in the immediate parts"); + assert_eq!( + probe.source, PROBE_JS, + "should serve the carried source verbatim" + ); + assert_eq!( + probe.sha256, PROBE_JS_SHA256, + "should carry the declared hash on the part" + ); assert!( - deferred.is_empty(), - "should have no deferred IDs when prebid is disabled" + registry.js_module_ids_immediate().contains(&"probe"), + "should list the carried module among the immediate module ids" ); } #[test] - fn js_module_ids_defer_prebid_shim_when_external_bundle_is_configured() { - let mut settings = crate::test_support::tests::create_test_settings(); - settings - .integrations - .insert_config( - "prebid", - &serde_json::json!({ - "enabled": true, - "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" - }), - ) - .expect("should update prebid config"); + fn a_carried_js_module_with_a_wrong_hash_is_rejected_at_registry_build() { + let settings = settings_naming("probe"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + lying_probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; - let registry = IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should reject a carried module whose declared hash is wrong"); + + let message = error.to_string(); + assert!( + message.contains("probe") && message.contains("does not match"), + "error should name the integration and say the hash does not match: {message}" + ); + assert!( + message.contains(ZERO_SHA256) && message.contains(PROBE_JS_SHA256), + "error should quote the declared and the actual hash: {message}" + ); + } + + #[test] + fn js_part_is_none_for_an_integration_registered_without_js() { + // The carried lookup would answer `Some` on its own, so this proves the + // without-JS check runs first. No built-in integration can stand in, because + // none registers without a browser script. + let settings = settings_naming("probe"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + disabled_carried_probe_registration, + validate_nothing, ) - .expect("should create registry"); + .with_module_name("testing.probe")]; + + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry"); + + assert!( + registry.integration_runs("probe"), + "should still register the integration" + ); + assert!( + registry.js_part("probe").is_none(), + "should not serve a module for an integration registered without JS" + ); + assert!( + !registry.js_module_ids().contains(&"probe"), + "should keep a without-JS integration out of the bundle module ids" + ); + } + #[test] + fn the_last_js_delivery_flag_set_on_the_builder_wins() { + let disabled_last = IntegrationRegistration::builder("probe") + .with_standalone_js() + .without_js() + .build(); assert!( - registry.js_module_ids().contains(&"prebid"), - "external bundle mode should include the prebid shim in embedded TSJS modules" + disabled_last.js_disabled && !disabled_last.js_standalone && !disabled_last.js_deferred, + "without_js after with_standalone_js should leave only disabled set" ); + + let deferred_last = IntegrationRegistration::builder("probe") + .with_standalone_js() + .with_deferred_js() + .build(); assert!( - !registry.js_module_ids_immediate().contains(&"prebid"), - "the prebid shim should not load in the immediate TSJS bundle" + deferred_last.js_deferred && !deferred_last.js_standalone && !deferred_last.js_disabled, + "with_deferred_js after with_standalone_js should leave only deferred set" ); + + let deferred_after_disabled = IntegrationRegistration::builder("probe") + .without_js() + .with_deferred_js() + .build(); assert!( - registry.js_module_ids_deferred().contains(&"prebid"), - "the prebid shim should load as a deferred TSJS module" + deferred_after_disabled.js_deferred + && !deferred_after_disabled.js_disabled + && !deferred_after_disabled.js_standalone, + "with_deferred_js after without_js should leave only deferred set" ); + + let standalone_last = IntegrationRegistration::builder("probe") + .without_js() + .with_deferred_js() + .with_standalone_js() + .build(); assert!( - registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), - "external bundle mode should register the first-party bundle route" + standalone_last.js_standalone + && !standalone_last.js_disabled + && !standalone_last.js_deferred, + "with_standalone_js last should leave only standalone set" ); } #[test] - fn js_module_ids_split_is_exhaustive() { + fn a_standalone_js_module_is_served_alone_and_not_in_the_bundle() { + let mut settings = crate::test_support::tests::create_test_settings(); + enable_standalone_module(&mut settings); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + + assert!( + !registry + .js_module_ids() + .contains(&test_support::request_fixture::ID), + "should keep a standalone module out of the bundle module ids" + ); + assert!( + registry + .js_part(test_support::request_fixture::ID) + .is_some(), + "should serve the standalone module as a part" + ); + assert_eq!( + registry.js_standalone_ids(), + vec![test_support::request_fixture::ID], + "should list the standalone module that runs" + ); + assert!( + registry.js_part("lockr").is_none(), + "should not serve a module for an integration that does not run" + ); + } + + #[test] + fn js_parts_all_covers_bundle_deferred_and_standalone_modules() { + let mut settings = settings_naming("probe"); + enable_deferred_module(&mut settings); + enable_standalone_module(&mut settings); + let extra = [carried_probe_builder()]; + + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry"); + + let ids = registry + .js_parts_all() + .into_iter() + .map(|part| part.id) + .collect::>(); + for expected in [ + "core", + "creative", + "probe", + test_support::deferred_fixture::ID, + test_support::request_fixture::ID, + ] { + assert_eq!( + ids.iter().filter(|id| **id == expected).count(), + 1, + "should list `{expected}` exactly once in {ids:?}" + ); + } + } + + /// A builder function for an integration that never registers, so its + /// preparer is the only thing the registry can take from it. + fn never_registering_builder( + _settings: &Settings, + ) -> Result, Report> { + Ok(None) + } + + /// Preparers cannot capture, because a builder holds plain function + /// pointers, so they record the order they ran in here. + static PREPARER_ORDER: Mutex> = Mutex::new(Vec::new()); + + fn record_first_preparer( + _settings: &Settings, + _request: &mut Request, + ) -> Result<(), Report> { + PREPARER_ORDER + .lock() + .expect("should lock the preparer order") + .push("first"); + Ok(()) + } + + fn record_second_preparer( + _settings: &Settings, + _request: &mut Request, + ) -> Result<(), Report> { + PREPARER_ORDER + .lock() + .expect("should lock the preparer order") + .push("second"); + Ok(()) + } + + /// Records whether the preparer registered after the failing one ran. + static PREPARER_AFTER_FAILURE: Mutex> = Mutex::new(Vec::new()); + + const FAILING_PREPARER_MESSAGE: &str = "probe preparer refused the request"; + + fn failing_preparer( + _settings: &Settings, + _request: &mut Request, + ) -> Result<(), Report> { + Err(Report::new(TrustedServerError::Configuration { + message: FAILING_PREPARER_MESSAGE.to_owned(), + })) + } + + fn record_after_failure_preparer( + _settings: &Settings, + _request: &mut Request, + ) -> Result<(), Report> { + PREPARER_AFTER_FAILURE + .lock() + .expect("should lock the after-failure record") + .push("after"); + Ok(()) + } + + fn plain_request() -> Request { + Request::builder() + .method(Method::GET) + .uri("https://publisher.example.com/article") + .body(EdgeBody::empty()) + .expect("should build request") + } + + #[test] + fn prepare_request_runs_every_preparer_in_registration_order_named_or_not() { + let settings = crate::test_support::tests::create_test_settings(); + let extra = [ + crate::integrations::IntegrationBuilder::new( + "probe-first", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-first") + .with_request_preparer(record_first_preparer), + crate::integrations::IntegrationBuilder::new( + "probe-second", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-second") + .with_request_preparer(record_second_preparer), + ]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry with request preparers"); + assert!( + !registry.integration_runs("probe-first"), + "should leave the first probe integration unnamed, so it does not run" + ); + assert!( + !registry.integration_runs("probe-second"), + "should leave the second probe integration unnamed, so it does not run" + ); + PREPARER_ORDER + .lock() + .expect("should lock the preparer order") + .clear(); + let mut request = plain_request(); + + registry + .prepare_request(&settings, &mut request) + .expect("should run every request preparer"); + + assert_eq!( + *PREPARER_ORDER + .lock() + .expect("should lock the preparer order"), + vec!["first", "second"], + "should run both preparers in registration order even though neither integration runs" + ); + } + + #[test] + fn prepare_request_surfaces_the_first_preparer_error() { let settings = crate::test_support::tests::create_test_settings(); - let mut settings_with_prebid = settings; - settings_with_prebid - .integrations - .insert_config( - "prebid", - &serde_json::json!({ - "enabled": true, - "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", - "timeout_ms": 1000, - "debug": false - }), + let extra = [ + crate::integrations::IntegrationBuilder::new( + "probe-failing", + "seam-probe", + never_registering_builder, + validate_nothing, ) - .expect("should insert prebid config"); + .with_module_name("testing.probe-failing") + .with_request_preparer(failing_preparer), + crate::integrations::IntegrationBuilder::new( + "probe-after-failure", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-after-failure") + .with_request_preparer(record_after_failure_preparer), + ]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry with request preparers"); + PREPARER_AFTER_FAILURE + .lock() + .expect("should lock the after-failure record") + .clear(); + let mut request = plain_request(); - let registry = IntegrationRegistry::with_plan( - &settings_with_prebid, - Arc::new( - crate::auction::compile_auction_plan(&settings_with_prebid) - .expect("should compile auction plan"), - ), - ) - .expect("should create registry"); + let error = registry + .prepare_request(&settings, &mut request) + .expect_err("should surface the failing preparer's error"); - let all = registry.js_module_ids(); - let mut recombined = registry.js_module_ids_immediate(); - recombined.extend(registry.js_module_ids_deferred()); - recombined.sort_unstable(); + assert!( + error.to_string().contains(FAILING_PREPARER_MESSAGE), + "should keep the preparer's message intact: {error}" + ); + assert!( + PREPARER_AFTER_FAILURE + .lock() + .expect("should lock the after-failure record") + .is_empty(), + "should not run a preparer registered after the failing one" + ); + } - let mut all_sorted = all; - all_sorted.sort_unstable(); + /// Writes one fixed insert, so a test can read the order hooks ran in. + struct FixedHeadInsert { + id: &'static str, + insert: &'static str, + } + + impl IntegrationHeadInjector for FixedHeadInsert { + fn integration_id(&self) -> &'static str { + self.id + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + vec![self.insert.to_owned()] + } + } + + const SECTION_INSERT: &str = ""; + const PLAN_INSERT: &str = ""; + + fn section_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe-section") + .with_head_injector(Arc::new(FixedHeadInsert { + id: "probe-section", + insert: SECTION_INSERT, + })) + .build(), + )) + } + + /// Registers when the plan's auction is enabled, whatever the sections + /// select. + fn plan_probe_registration( + _settings: &Settings, + plan: &AuctionPlan, + ) -> Result, Report> { + Ok(plan.enabled().then(|| { + IntegrationRegistration::builder("probe-plan") + .with_head_injector(Arc::new(FixedHeadInsert { + id: "probe-plan", + insert: PLAN_INSERT, + })) + .build() + })) + } + + #[test] + fn a_registration_from_the_plan_runs_unselected_and_ahead_of_a_section_s_module() { + let mut settings = settings_naming("testing.probe-section"); + // The section's module is listed first, and what the plan registers + // still goes ahead of it. + let extra = [ + crate::integrations::IntegrationBuilder::new( + "probe-section", + "seam-probe", + section_probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe-section"), + crate::integrations::IntegrationBuilder::new( + "probe-plan", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-plan") + .with_plan_registration(plan_probe_registration), + ]; + let probe_inserts = |registry: &IntegrationRegistry| { + let document_state = IntegrationDocumentState::default(); + registry + .head_inserts(&IntegrationHtmlContext { + request_host: "publisher.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + document_state: &document_state, + }) + .into_iter() + .filter(|insert| insert == SECTION_INSERT || insert == PLAN_INSERT) + .collect::>() + }; + + settings.auction.enabled = false; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry"); + assert!( + !registry.integration_runs("probe-plan"), + "should register nothing where the function finds nothing in the plan" + ); + assert_eq!(probe_inserts(®istry), vec![SECTION_INSERT]); + settings.auction.enabled = true; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry"); + assert!( + registry.integration_runs("probe-plan"), + "should register from the plan a module no section selects" + ); assert_eq!( - recombined, all_sorted, - "should reconstruct full module list from immediate + deferred" + probe_inserts(®istry), + vec![PLAN_INSERT, SECTION_INSERT], + "should run the hooks of what the plan registered ahead of a section's module" + ); + } + + /// Appends to a header on each run, so a test can count the runs on the + /// request itself. + fn counting_preparer( + _settings: &Settings, + request: &mut Request, + ) -> Result<(), Report> { + request + .headers_mut() + .append("x-probe-prepared", HeaderValue::from_static("1")); + Ok(()) + } + + #[test] + fn prepare_request_runs_the_preparers_once_for_a_request() { + let settings = crate::test_support::tests::create_test_settings(); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe-counting", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-counting") + .with_request_preparer(counting_preparer)]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry with a request preparer"); + let mut request = plain_request(); + + registry + .prepare_request(&settings, &mut request) + .expect("should prepare the request"); + registry + .prepare_request(&settings, &mut request) + .expect("should accept a request that is already prepared"); + + assert_eq!( + request.headers().get_all("x-probe-prepared").iter().count(), + 1, + "should run a preparer once however often the request is prepared" + ); + + let mut next = plain_request(); + registry + .prepare_request(&settings, &mut next) + .expect("should prepare the next request"); + assert_eq!( + next.headers().get_all("x-probe-prepared").iter().count(), + 1, + "should prepare each request" + ); + } + + /// What the second probe's request hook leaves for its finalizer. + #[derive(Debug, PartialEq)] + struct ProbeNote(&'static str); + + fn first_finalizer(_state: &IntegrationRequestState, response: &mut Response) { + response + .headers_mut() + .append("x-probe-finalized", HeaderValue::from_static("first")); + } + + fn second_finalizer(state: &IntegrationRequestState, response: &mut Response) { + let value = match state.get::("probe-second") { + Some(_) => "second-with-its-note", + None => "second", + }; + response + .headers_mut() + .append("x-probe-finalized", HeaderValue::from_static(value)); + } + + #[test] + fn finalize_response_runs_every_finalizer_in_registration_order_with_the_request_state() { + let settings = crate::test_support::tests::create_test_settings(); + let extra = [ + crate::integrations::IntegrationBuilder::new( + "probe-first", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-first") + .with_response_finalizer(first_finalizer), + crate::integrations::IntegrationBuilder::new( + "probe-second", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-second") + .with_response_finalizer(second_finalizer), + ]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build registry with response finalizers"); + let finalized = |response: &Response| { + response + .headers() + .get_all("x-probe-finalized") + .iter() + .map(|value| value.to_str().expect("should be text").to_owned()) + .collect::>() + }; + + let mut request = plain_request(); + IntegrationRequestState::insert(&mut request, "probe-second", ProbeNote("left")); + let mut response = Response::builder() + .body(EdgeBody::empty()) + .expect("should build response"); + registry.finalize_response(&IntegrationRequestState::of(&request), &mut response); + + assert_eq!( + finalized(&response), + vec!["first", "second-with-its-note"], + "should run both finalizers in registration order, neither integration \ + running, and hand each what was left on the request" + ); + + let mut unmarked = Response::builder() + .body(EdgeBody::empty()) + .expect("should build response"); + registry.finalize_response(&IntegrationRequestState::default(), &mut unmarked); + assert_eq!( + finalized(&unmarked), + vec!["first", "second"], + "should hand a finalizer no state for a request nothing was left on" + ); + } + + #[test] + fn what_a_module_leaves_on_a_request_reaches_a_document_when_it_is_seeded() { + let mut request = plain_request(); + assert!( + IntegrationRequestState::of(&request).is_empty(), + "should hold nothing for a request no module left a value on" + ); + + IntegrationRequestState::insert(&mut request, "probe", ProbeNote("first")); + IntegrationRequestState::insert(&mut request, "probe", ProbeNote("second")); + IntegrationRequestState::insert(&mut request, "other", ProbeNote("other")); + let state = IntegrationRequestState::of(&request); + + assert!(!state.is_empty(), "should hold what was left"); + assert_eq!( + state.get::("probe").as_deref(), + Some(&ProbeNote("second")), + "should keep the last value of one type left for an integration" + ); + assert!( + state.get::("absent").is_none(), + "should hold nothing for an integration that left nothing" + ); + + let document = IntegrationDocumentState::default(); + assert!( + document.get::("probe").is_none(), + "should start a document with none of the request's values" + ); + state.seed(&document); + assert_eq!( + document.get::("probe").as_deref(), + Some(&ProbeNote("second")), + "should copy the value into the document's state" + ); + assert_eq!( + document.get::("other").as_deref(), + Some(&ProbeNote("other")), + "should copy every integration's value" + ); + } + + #[test] + fn the_request_stand_in_marks_a_navigation_and_strips_its_query() { + use super::test_support::request_fixture; + + let settings = crate::test_support::tests::create_test_settings(); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let mut navigation = Request::builder() + .method(Method::GET) + .uri("https://publisher.example.com/article?fixture_request=1&keep=yes") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build request"); + let mut subresource = Request::builder() + .method(Method::GET) + .uri("https://publisher.example.com/data.json?fixture_request=1") + .header("sec-fetch-dest", "empty") + .body(EdgeBody::empty()) + .expect("should build request"); + + registry + .prepare_request(&settings, &mut navigation) + .expect("should prepare the navigation"); + registry + .prepare_request(&settings, &mut subresource) + .expect("should prepare the subresource request"); + + assert_eq!( + navigation.uri().query(), + Some("keep=yes"), + "should strip the stand-in's query and keep the rest" + ); + assert!( + IntegrationRequestState::of(&navigation) + .get::(request_fixture::ID) + .is_some(), + "should leave the mark on a document navigation, selected or not" + ); + assert_eq!( + subresource.uri().query(), + None, + "should strip the stand-in's query from any request" + ); + assert!( + IntegrationRequestState::of(&subresource).is_empty(), + "should leave no mark on a request for something other than a document" + ); + } + + /// Example country code returned by the test geo module. `ZZ` is the + /// user-assigned code, so it names no real place. + const GEO_PROBE_COUNTRY: &str = "ZZ"; + + /// A geo module that resolves one fixed location, so a test can tell the + /// module's module apart from the "no location" one. + #[derive(Debug)] + struct FixedCountryGeo; + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformGeo for FixedCountryGeo { + async fn lookup( + &self, + _client_ip: Option, + _services: &crate::platform::RuntimeServices, + ) -> Result, Report> { + Ok(Some(GeoInfo { + city: "Example City".to_owned(), + country: GEO_PROBE_COUNTRY.to_owned(), + continent: "Example".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + })) + } + } + + /// The name the `geo-probe` registration's geo module is selected by. + const GEO_PROBE_MODULE: &str = "testing.geo-probe"; + + /// Builds a `geo-probe` registration that declares a geo module. + fn geo_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("geo-probe") + .with_geo_module(GEO_PROBE_MODULE, Arc::new(FixedCountryGeo)) + .build(), + )) + } + + /// A device module that declares it needs a permission, which nothing can + /// enforce per request yet. + #[derive(Debug)] + struct PermissionDemandingDevice; + + /// The name the `device-probe` registration's device module is selected + /// by. + const DEVICE_PROBE_MODULE: &str = "testing.device-probe"; + + #[async_trait::async_trait(?Send)] + impl crate::ec::device::DeviceModule for PermissionDemandingDevice { + fn id(&self) -> &'static str { + DEVICE_PROBE_MODULE + } + + async fn detect( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _services: &crate::platform::RuntimeServices, + ) -> crate::ec::device::DeviceSignals { + crate::ec::device::DeviceSignals { + is_mobile: 2, + ja4_class: None, + platform_class: None, + h2_fp_hash: None, + known_browser: None, + looks_like_browser: false, + } + } + + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + .with(crate::permissions::Permission::StoreOnDevice) + } + } + + fn device_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("device-probe") + .with_device_module(DEVICE_PROBE_MODULE, Arc::new(PermissionDemandingDevice)) + .build(), + )) + } + + fn device_probe_builders() -> [crate::integrations::IntegrationBuilder; 1] { + [crate::integrations::IntegrationBuilder::new( + "device-probe", + "seam-probe", + device_probe_registration, + validate_nothing, + ) + .with_module_name(DEVICE_PROBE_MODULE)] + } + + #[test] + fn a_modules_device_module_declaring_permissions_is_refused_at_startup() { + let mut settings = settings_naming("device-probe"); + settings.device.module = Some(DEVICE_PROBE_MODULE.to_owned()); + + let error = IntegrationRegistry::with_registrations(&settings, &device_probe_builders()) + .err() + .expect("should refuse a device module whose permissions nothing can enforce"); + + let rendered = format!("{error:?}"); + assert!( + rendered.contains("device-probe"), + "the failure should name the module, got: {rendered}" + ); + assert!( + rendered.contains(&format!( + "requiring `{}`", + crate::permissions::Permission::StoreOnDevice.as_str() + )), + "the failure should say why it was refused, got: {rendered}" + ); + } + + fn geo_probe_builders() -> [crate::integrations::IntegrationBuilder; 1] { + [crate::integrations::IntegrationBuilder::new( + "geo-probe", + "seam-probe", + geo_probe_registration, + validate_nothing, + ) + .with_module_name(GEO_PROBE_MODULE)] + } + + /// Settings whose `[geo] module` names `module`, which may be a value + /// core resolves itself, such as `none` or `platform`. + fn settings_with_geo_selector(module: &str) -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.geo.module = Some(module.to_owned()); + settings + } + + /// The same, with the module selected in its section as well, which is + /// what a deployment running a module's geo module writes. + fn settings_selecting_geo_module(name: &str) -> Settings { + let mut settings = settings_with_geo_selector(name); + let section = name.split_once('.').map_or("testing", |(folder, _)| folder); + settings.select_module(section, name); + settings + } + + #[tokio::test] + async fn geo_selector_resolves_the_geo_module_the_selected_module_declares() { + let settings = settings_selecting_geo_module(GEO_PROBE_MODULE); + + let registry = IntegrationRegistry::with_registrations(&settings, &geo_probe_builders()) + .expect("should build registry with a module geo module"); + + let module = registry + .geo_module() + .expect("should resolve the module's geo module"); + let resolved = module + .lookup(None, &noop_services()) + .await + .expect("should look up without failing") + .expect("should resolve a location"); + assert_eq!( + resolved.country, GEO_PROBE_COUNTRY, + "should resolve through the module's own module" + ); + } + + #[tokio::test] + async fn geo_module_none_resolves_no_location() { + let settings = settings_with_geo_selector("none"); + + let registry = IntegrationRegistry::with_registrations(&settings, &geo_probe_builders()) + .expect("should build registry with the disabled geo module"); + + let module = registry + .geo_module() + .expect("should resolve the disabled geo module"); + assert!( + module + .lookup(None, &noop_services()) + .await + .expect("should look up without failing") + .is_none(), + "should resolve no location when the selector is `none`" + ); + } + + #[tokio::test] + async fn an_unset_geo_selector_resolves_the_disabled_module_and_platform_opts_in() { + // Unset is the permission model's privacy default: it resolves the + // disabled module, so no client IP ever reaches a host geo service. + // It is deliberately not the same as leaving the adapter's own lookup + // in place, which is what `platform` spells. + let settings = crate::test_support::tests::create_test_settings(); + assert!( + settings.geo.module.is_none(), + "the shared test settings should leave the geo selector unset" + ); + + let registry = IntegrationRegistry::with_registrations(&settings, &geo_probe_builders()) + .expect("should build registry with an unset geo selector"); + + let module = registry + .geo_module() + .expect("an unset selector should resolve the disabled module, not nothing"); + assert!( + module + .lookup(None, &noop_services()) + .await + .expect("should look up without failing") + .is_none(), + "the disabled module should resolve no location and make no host call" + ); + + // `platform` is the explicit opt-in to the adapter's own lookup, and it + // is the one case that resolves nothing here so the adapter's module + // stands. + let platform = settings_with_geo_selector(GEO_MODULE_PLATFORM); + let registry = IntegrationRegistry::with_registrations(&platform, &geo_probe_builders()) + .expect("should build registry with the platform geo selector"); + assert!( + registry.geo_module().is_none(), + "`platform` should leave the adapter's own host lookup in place" + ); + } + + #[test] + fn geo_selector_rejects_a_module_that_declares_no_geo_module() { + let settings = settings_selecting_geo_module("testing.probe"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should reject a module that declares no geo module"); + + let message = error.to_string(); + assert!( + message.contains("`[geo] module` names `testing.probe`") + && message.contains("It runs no geo module") + && message.contains("is selected and supplies no geo module"), + "error should name the module and say it supplies no geo module: {message}" + ); + } + + #[test] + fn geo_selector_rejects_a_module_no_section_selects() { + // The module is registered, but no section selects it, so its geo + // module is not there to select. + let settings = settings_with_geo_selector("testing.probe-unnamed"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe-unnamed", + "seam-probe", + never_registering_builder, + validate_nothing, + ) + .with_module_name("testing.probe-unnamed")]; + + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should reject a module no section selects"); + + let message = error.to_string(); + assert!( + message.contains("`[geo] module` names `testing.probe-unnamed`") + && message.contains("is a module no section selects"), + "error should name the module and say no section selects it: {message}" + ); + } + + /// An id no builder supplies is refused here, where the adapter's and a + /// vendor crate's builders are known, rather than in the settings. + #[test] + fn an_id_no_builder_supplies_is_refused_at_registry_build() { + let settings = settings_naming("a_vendors_own_integration"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should refuse an id no builder in this deployment supplies"); + + let message = error.to_string(); + assert!( + message.contains("a_vendors_own_integration"), + "should name the id nothing supplies: {message}" + ); + assert!( + message.contains("The testing modules it supplies are") && message.contains("probe"), + "should list the modules of that type this deployment does supply: {message}" + ); + } + + /// A demand or ad server implementation is not a page integration, so a + /// section naming one is refused like any name no builder supplies. + #[test] + fn an_auction_implementation_named_in_a_section_is_refused() { + for (section, name) in [ + ("auction", "fixture"), + ("auction", "ad-server.fixture"), + ("auction", "plain-fixture"), + ] { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.select_module(section, name); + + let error = IntegrationRegistry::new(&settings) + .err() + .expect("should refuse an implementation named as a page module"); + + let message = error.to_string(); + assert!( + message.contains(&format!("[{section}] selects `{name}`")), + "should name the section and the implementation: {message}" + ); + } + } + + /// The same id is accepted once a builder supplies it, so a vendor crate + /// an adapter composes in is named the same way a built-in is. + #[test] + fn an_id_a_supplied_builder_claims_is_accepted() { + let settings = settings_naming("probe"); + let extra = [crate::integrations::IntegrationBuilder::new( + "probe", + "seam-probe", + probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should accept an id a supplied builder claims"); + + assert!( + registry.integration_runs("probe"), + "the named module should run" + ); + } + + #[test] + fn geo_selector_rejects_a_name_no_module_supplies() { + // Nothing supplies the name, as a geo module or as a module at all. + let settings = settings_with_geo_selector("absent-module"); + + let error = IntegrationRegistry::with_registrations(&settings, &geo_probe_builders()) + .err() + .expect("should reject a selector naming nothing this deployment runs"); + + let message = error.to_string(); + assert!( + message.contains("`[geo] module` names `absent-module`") + && message.contains("which no module this deployment runs supplies") + && !message.contains("no section selects"), + "error should name what was written and say nothing supplies it: {message}" + ); + } + + /// A device module that needs no permission, for a registration that + /// supplies more than one type of module. + #[derive(Debug)] + struct ExampleDevice; + + #[async_trait::async_trait(?Send)] + impl crate::ec::device::DeviceModule for ExampleDevice { + fn id(&self) -> &'static str { + "device.example" + } + + async fn detect( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _services: &crate::platform::RuntimeServices, + ) -> crate::ec::device::DeviceSignals { + crate::ec::device::DeviceSignals { + is_mobile: 1, + ja4_class: None, + platform_class: Some("example".to_owned()), + h2_fp_hash: None, + known_browser: None, + looks_like_browser: true, + } + } + } + + /// An Edge Cookie module that answers to `edgecookie.example`. + #[derive(Debug)] + struct ExampleEc; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieModule for ExampleEc { + fn id(&self) -> &'static str { + "edgecookie.example" + } + + fn code(&self) -> crate::ec::module::ModuleCode { + crate::module_code!("t0rg") + } + + async fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::module::IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(crate::ec::module::GeneratedEdgeCookie::default()) + } + } + + /// Builds one registration that supplies a geo, a device and an Edge + /// Cookie module, each under the name of its own type. + fn example_vendor_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("example-vendor") + .with_geo_module("geo.example", Arc::new(FixedCountryGeo)) + .with_device_module("device.example", Arc::new(ExampleDevice)) + .with_ec_module("edgecookie.example", Arc::new(ExampleEc)) + .build(), + )) + } + + fn example_vendor_builders() -> [crate::integrations::IntegrationBuilder; 1] { + [crate::integrations::IntegrationBuilder::new( + "example-vendor", + "seam-probe", + example_vendor_registration, + validate_nothing, + ) + .with_module_name("testing.example-vendor")] + } + + /// One registration can supply a module of each type, and each selector + /// finds its own by the short name, the type folder left off. + #[test] + fn each_selector_resolves_its_own_type_of_module_from_one_registration() { + let mut settings = settings_naming("example-vendor"); + settings.geo.module = Some("example".to_owned()); + settings.device.module = Some("example".to_owned()); + settings.ec.module = Some(EcModuleSelection::from("example")); + + let registry = + IntegrationRegistry::with_registrations(&settings, &example_vendor_builders()) + .expect("should build registry with one module of each type"); + + assert!( + registry.geo_module().is_some(), + "`[geo] module = \"example\"` should resolve `geo.example`" + ); + assert_eq!( + registry + .device_module() + .expect("`[device] module = \"example\"` should resolve `device.example`") + .id(), + "device.example", + "should resolve the registration's device module" + ); + assert_eq!( + registry + .ec_module() + .expect("`[ec] module = \"example\"` should resolve `edgecookie.example`") + .id(), + "edgecookie.example", + "should resolve the registration's Edge Cookie module" + ); + } + + /// A name written in full selects the same module, and a short name is + /// read within the selector's own type only. + #[test] + fn a_selector_reads_a_full_name_and_keeps_a_short_name_within_its_type() { + let mut settings = settings_naming("example-vendor"); + settings.geo.module = Some("geo.example".to_owned()); + + let registry = + IntegrationRegistry::with_registrations(&settings, &example_vendor_builders()) + .expect("should resolve a geo module written in full"); + assert!( + registry.geo_module().is_some(), + "`[geo] module = \"geo.example\"` should resolve the module" + ); + + // `device.example` is a device module, so `[geo]` does not find it + // under the short name of another type. + let mut settings = settings_naming("example-vendor"); + settings.geo.module = Some("device.example".to_owned()); + let error = IntegrationRegistry::with_registrations(&settings, &example_vendor_builders()) + .err() + .expect("should refuse a device module named as a geo module"); + let message = error.to_string(); + assert!( + message.contains("`[geo] module` names `device.example`") + && message.contains("The geo modules it runs are [example]"), + "error should list the geo modules by the name the section writes: {message}" + ); + } + + /// `[ec] module` may name a label whose block names the implementation, + /// and the registry resolves the implementation, as core does. + #[test] + fn ec_selector_resolves_the_implementation_a_labelled_block_names() { + let mut settings = settings_naming("example-vendor"); + settings.ec.module = Some(EcModuleSelection::from("primary")); + settings.ec.module_blocks.insert( + "primary".to_owned(), + crate::settings::EcModuleBlock { + implementation: Some("example".to_owned()), + settings: crate::settings::EcModuleSettings::Injected(serde_json::Map::new()), + }, + ); + + let registry = + IntegrationRegistry::with_registrations(&settings, &example_vendor_builders()) + .expect("should build registry with a labelled Edge Cookie selection"); + + assert_eq!( + registry + .ec_module() + .expect("should resolve the implementation the block names") + .id(), + "edgecookie.example", + "should resolve the registration's Edge Cookie module" + ); + } + + /// Core's own Edge Cookie modules are not the registry's to supply, so a + /// selection naming one resolves nothing here and is not an error. + #[test] + fn ec_selector_naming_a_module_of_cores_resolves_nothing_here() { + let settings = settings_naming("example-vendor"); + assert!( + settings + .ec + .module + .as_ref() + .is_some_and(|selection| selection.key() == crate::ec::module::HMAC_MODULE_KEY), + "the shared test settings should select core's HMAC module" + ); + + let registry = + IntegrationRegistry::with_registrations(&settings, &example_vendor_builders()) + .expect("should build registry with core's own Edge Cookie module selected"); + + assert!( + registry.ec_module().is_none(), + "core resolves its own module, so the registry should supply none" + ); + } + + fn misnamed_ec_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("misnamed") + .with_ec_module("edgecookie.another", Arc::new(ExampleEc)) + .build(), + )) + } + + /// Core checks the selected Edge Cookie module by its own id, so one + /// declared under another name is refused where the mistake is made. + #[test] + fn an_edge_cookie_module_declared_under_another_name_is_refused() { + let settings = settings_naming("misnamed"); + let extra = [crate::integrations::IntegrationBuilder::new( + "misnamed", + "seam-probe", + misnamed_ec_registration, + validate_nothing, + ) + .with_module_name("testing.misnamed")]; + + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should refuse an Edge Cookie module declared under another name"); + + let message = error.to_string(); + assert!( + message.contains("`edgecookie.another`") && message.contains("`edgecookie.example`"), + "error should give both names: {message}" + ); + } + + fn second_geo_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("geo-second") + .with_geo_module(GEO_PROBE_MODULE, Arc::new(FixedCountryGeo)) + .build(), + )) + } + + /// Two registrations supplying a module of one type under one name would + /// leave a selector meaning either, so the pair is refused. + #[test] + fn two_registrations_declaring_one_geo_module_name_are_refused() { + let mut settings = settings_naming("geo-probe"); + settings.select_module("testing", "testing.geo-second"); + let extra = [ + geo_probe_builders()[0], + crate::integrations::IntegrationBuilder::new( + "geo-second", + "seam-probe", + second_geo_registration, + validate_nothing, + ) + .with_module_name("testing.geo-second"), + ]; + + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should refuse one geo module name declared twice"); + + let message = error.to_string(); + assert!( + message.contains("the geo module `testing.geo-probe` is declared twice") + && message.contains("`geo-probe`") + && message.contains("`geo-second`"), + "error should name the module and both integrations: {message}" ); } } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index a3a4177735..52b0528b0d 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -11,8 +11,6 @@ //! - [`error`]: Error types and error handling utilities //! - [`consent`]: Consent signal extraction and logging //! - [`geo`]: Geographic location utilities and DMA code extraction -//! - [`models`]: Data models for ad serving and callbacks -//! - [`integrations::prebid`]: Prebid integration and real-time bidding support //! - [`settings`]: Configuration management and validation //! - [`streaming_replacer`]: Streaming URL replacement for large responses //! - [`ec`]: Edge Cookie (EC) identity subsystem — ID generation, consent gating, lifecycle @@ -46,22 +44,23 @@ pub mod cookies; pub mod creative; pub mod creative_opportunities; pub mod ec; -pub(crate) mod edge_cookie; +pub mod edge_cookie; pub mod error; pub mod evidence; pub mod geo; pub mod host_header; -pub(crate) mod host_rewrite; +pub mod host_rewrite; pub mod html_processor; pub mod http_util; pub mod integrations; -pub mod models; pub mod module_name; +pub(crate) mod module_secrets; pub mod openrtb; pub mod permission_signal; pub mod permissions; pub mod platform; pub mod price_bucket; +pub mod provider_table; pub mod proxy; pub mod publisher; pub mod redacted; @@ -79,6 +78,7 @@ pub mod tdl; pub mod test_support; pub mod tester_cookie; pub mod tsjs; +pub mod tsjs_bundle; pub mod version_header; #[cfg(test)] diff --git a/crates/trusted-server-core/src/migration_guards.rs b/crates/trusted-server-core/src/migration_guards.rs index 17b3d624f6..59deccec97 100644 --- a/crates/trusted-server-core/src/migration_guards.rs +++ b/crates/trusted-server-core/src/migration_guards.rs @@ -27,206 +27,18 @@ fn fastly_sdk_pattern() -> regex::Regex { .expect("should compile migration guard regex") } +// The list of files this guard checks is generated by `build.rs` from every +// `.rs` file under `src`, so a file added to core joins the guard on its own +// and a file that leaves core (a vendor integration moving to its own crate, +// for instance) leaves the guard without anyone editing this file. The +// generated file defines `CHECKED_SOURCES`. +// +// `migration_guards.rs` itself is left out, because the banned pattern appears +// here as the regex literal the guard matches with. +include!(concat!(env!("OUT_DIR"), "/migration_guard_sources.rs")); + fn checked_sources() -> &'static [(&'static str, &'static str)] { - &[ - ( - "asset_image_optimizer.rs", - include_str!("asset_image_optimizer.rs"), - ), - ("auction/config.rs", include_str!("auction/config.rs")), - ("auction/context.rs", include_str!("auction/context.rs")), - ("auction/endpoints.rs", include_str!("auction/endpoints.rs")), - ("auction/formats.rs", include_str!("auction/formats.rs")), - ("auction/mod.rs", include_str!("auction/mod.rs")), - ( - "auction/orchestrator.rs", - include_str!("auction/orchestrator.rs"), - ), - ("auction/provider.rs", include_str!("auction/provider.rs")), - ( - "auction/test_support.rs", - include_str!("auction/test_support.rs"), - ), - ("auction/types.rs", include_str!("auction/types.rs")), - ( - "auction_config_types.rs", - include_str!("auction_config_types.rs"), - ), - ("auth.rs", include_str!("auth.rs")), - ( - "consent/extraction.rs", - include_str!("consent/extraction.rs"), - ), - ("consent/gpp.rs", include_str!("consent/gpp.rs")), - ( - "consent/jurisdiction.rs", - include_str!("consent/jurisdiction.rs"), - ), - ("consent/mod.rs", include_str!("consent/mod.rs")), - ("consent/tcf.rs", include_str!("consent/tcf.rs")), - ("consent/types.rs", include_str!("consent/types.rs")), - ( - "consent/us_privacy.rs", - include_str!("consent/us_privacy.rs"), - ), - ("consent_config.rs", include_str!("consent_config.rs")), - ("constants.rs", include_str!("constants.rs")), - ("cookies.rs", include_str!("cookies.rs")), - ("creative.rs", include_str!("creative.rs")), - ("ec/auth.rs", include_str!("ec/auth.rs")), - ("ec/batch_sync.rs", include_str!("ec/batch_sync.rs")), - ("ec/consent.rs", include_str!("ec/consent.rs")), - ("ec/cookies.rs", include_str!("ec/cookies.rs")), - ("ec/device.rs", include_str!("ec/device.rs")), - ("ec/eids.rs", include_str!("ec/eids.rs")), - ("ec/finalize.rs", include_str!("ec/finalize.rs")), - ("ec/generation.rs", include_str!("ec/generation.rs")), - ("ec/identify.rs", include_str!("ec/identify.rs")), - ("ec/kv.rs", include_str!("ec/kv.rs")), - ("ec/kv_types.rs", include_str!("ec/kv_types.rs")), - ("ec/mod.rs", include_str!("ec/mod.rs")), - ("ec/partner.rs", include_str!("ec/partner.rs")), - ("ec/prebid_eids.rs", include_str!("ec/prebid_eids.rs")), - ("ec/pull_sync.rs", include_str!("ec/pull_sync.rs")), - ("ec/rate_limiter.rs", include_str!("ec/rate_limiter.rs")), - ("ec/registry.rs", include_str!("ec/registry.rs")), - ("edge_cookie.rs", include_str!("edge_cookie.rs")), - ("error.rs", include_str!("error.rs")), - ("geo.rs", include_str!("geo.rs")), - ("host_header.rs", include_str!("host_header.rs")), - ("host_rewrite.rs", include_str!("host_rewrite.rs")), - ("html_processor.rs", include_str!("html_processor.rs")), - ("http_util.rs", include_str!("http_util.rs")), - ( - "integrations/adserver_mock.rs", - include_str!("integrations/adserver_mock.rs"), - ), - ("integrations/aps.rs", include_str!("integrations/aps.rs")), - ( - "integrations/datadome.rs", - include_str!("integrations/datadome.rs"), - ), - ( - "integrations/didomi.rs", - include_str!("integrations/didomi.rs"), - ), - ( - "integrations/google_tag_manager.rs", - include_str!("integrations/google_tag_manager.rs"), - ), - ("integrations/gpt.rs", include_str!("integrations/gpt.rs")), - ( - "integrations/gpt_diagnostics.rs", - include_str!("integrations/gpt_diagnostics.rs"), - ), - ( - "integrations/lockr.rs", - include_str!("integrations/lockr.rs"), - ), - ("integrations/mod.rs", include_str!("integrations/mod.rs")), - ( - "integrations/nextjs/mod.rs", - include_str!("integrations/nextjs/mod.rs"), - ), - ( - "integrations/nextjs/rsc.rs", - include_str!("integrations/nextjs/rsc.rs"), - ), - ( - "integrations/nextjs/rsc_placeholders.rs", - include_str!("integrations/nextjs/rsc_placeholders.rs"), - ), - ( - "integrations/nextjs/rsc_stream.rs", - include_str!("integrations/nextjs/rsc_stream.rs"), - ), - ( - "integrations/nextjs/script_rewriter.rs", - include_str!("integrations/nextjs/script_rewriter.rs"), - ), - ( - "integrations/nextjs/shared.rs", - include_str!("integrations/nextjs/shared.rs"), - ), - ( - "integrations/permutive.rs", - include_str!("integrations/permutive.rs"), - ), - ( - "integrations/prebid.rs", - include_str!("integrations/prebid.rs"), - ), - ( - "integrations/registry.rs", - include_str!("integrations/registry.rs"), - ), - ( - "integrations/sourcepoint.rs", - include_str!("integrations/sourcepoint.rs"), - ), - ( - "integrations/testlight.rs", - include_str!("integrations/testlight.rs"), - ), - ("lib.rs", include_str!("lib.rs")), - ("models.rs", include_str!("models.rs")), - ("openrtb.rs", include_str!("openrtb.rs")), - ("platform/error.rs", include_str!("platform/error.rs")), - ("platform/http.rs", include_str!("platform/http.rs")), - ( - "platform/image_optimizer.rs", - include_str!("platform/image_optimizer.rs"), - ), - ("platform/kv.rs", include_str!("platform/kv.rs")), - ("platform/mod.rs", include_str!("platform/mod.rs")), - ( - "platform/test_support.rs", - include_str!("platform/test_support.rs"), - ), - ("platform/traits.rs", include_str!("platform/traits.rs")), - ("platform/types.rs", include_str!("platform/types.rs")), - ("proxy.rs", include_str!("proxy.rs")), - ("publisher.rs", include_str!("publisher.rs")), - ("redacted.rs", include_str!("redacted.rs")), - ( - "request_signing/discovery.rs", - include_str!("request_signing/discovery.rs"), - ), - ( - "request_signing/endpoints.rs", - include_str!("request_signing/endpoints.rs"), - ), - ( - "request_signing/jwks.rs", - include_str!("request_signing/jwks.rs"), - ), - ( - "request_signing/mod.rs", - include_str!("request_signing/mod.rs"), - ), - ( - "request_signing/rotation.rs", - include_str!("request_signing/rotation.rs"), - ), - ( - "request_signing/signing.rs", - include_str!("request_signing/signing.rs"), - ), - ("rsc_flight.rs", include_str!("rsc_flight.rs")), - ("s3_sigv4.rs", include_str!("s3_sigv4.rs")), - ("settings.rs", include_str!("settings.rs")), - ("settings_data.rs", include_str!("settings_data.rs")), - ( - "streaming_processor.rs", - include_str!("streaming_processor.rs"), - ), - ( - "streaming_replacer.rs", - include_str!("streaming_replacer.rs"), - ), - ("test_support.rs", include_str!("test_support.rs")), - ("tsjs.rs", include_str!("tsjs.rs")), - ] + CHECKED_SOURCES } #[test] @@ -241,3 +53,33 @@ fn core_modules_do_not_reference_the_fastly_sdk() { ); } } + +/// A generation bug that produced an empty or truncated list would leave the +/// guard above passing while checking nothing, so this pins the list to the +/// tree it is meant to cover. +#[test] +fn the_generated_list_covers_the_crate() { + let sources = checked_sources(); + + assert!( + sources.len() > 100, + "should check every core source file, found {}", + sources.len() + ); + assert!( + sources.iter().any(|&(path, _)| path == "lib.rs"), + "should check the crate root" + ); + assert!( + sources + .iter() + .any(|&(path, _)| path == "integrations/registry.rs"), + "should check files in subdirectories" + ); + assert!( + !sources + .iter() + .any(|&(path, _)| path == "migration_guards.rs"), + "should leave out the guard's own source, which holds the banned pattern" + ); +} diff --git a/crates/trusted-server-core/src/models.rs b/crates/trusted-server-core/src/models.rs deleted file mode 100644 index 87cadcb94f..0000000000 --- a/crates/trusted-server-core/src/models.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Data models for ad serving and callbacks. -//! -//! This module defines the structures used for communication with ad servers -//! and tracking callbacks. - -use serde::Deserialize; - -/// Response from an ad server containing creative details. -/// -/// Contains all the information needed to display an ad and track -/// its performance through various callbacks. -#[allow( - dead_code, - reason = "model includes fields deserialized for external ad-server compatibility" -)] -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct AdResponse { - /// Network identifier for the ad network. - pub network_id: String, - /// Site identifier where the ad will be displayed. - pub site_id: String, - /// Page identifier within the site. - pub page_id: String, - /// Format identifier for the ad format. - pub format_id: String, - /// Advertiser identifier. - pub advertiser_id: String, - /// Campaign identifier. - pub campaign_id: String, - /// Insertion order identifier. - pub insertion_id: String, - /// Creative identifier. - pub creative_id: String, - /// URL of the creative asset to display. - pub creative_url: String, - /// List of tracking callbacks for various events. - pub callbacks: Vec, -} - -/// Tracking callback for ad events. -/// -/// Represents a URL that should be called when specific ad events occur, -/// such as impressions, clicks, or viewability milestones. -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct Callback { - /// Type of callback (e.g., "impression", "click", "viewable"). - #[serde(rename = "type")] - pub callback_type: String, - /// URL to call when the event occurs. - pub url: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_callback_deserialization() { - let json_data = json!({ - "type": "impression", - "url": "https://example.com/track/impression" - }); - - let callback: Callback = - serde_json::from_value(json_data).expect("should deserialize callback"); - assert_eq!(callback.callback_type, "impression"); - assert_eq!(callback.url, "https://example.com/track/impression"); - } - - #[test] - fn test_ad_response_full_deserialization() { - let json_data = json!({ - "networkId": "12345", - "siteId": "67890", - "pageId": "11111", - "formatId": "22222", - "advertiserId": "33333", - "campaignId": "44444", - "insertionId": "55555", - "creativeId": "66666", - "creativeUrl": "https://cdn.example.com/creative/12345.jpg", - "callbacks": [ - { - "type": "impression", - "url": "https://track.example.com/impression/12345" - }, - { - "type": "click", - "url": "https://track.example.com/click/12345" - }, - { - "type": "viewability", - "url": "https://track.example.com/viewability/12345" - } - ] - }); - - let ad_response: AdResponse = - serde_json::from_value(json_data).expect("should deserialize ad response"); - - assert_eq!(ad_response.network_id, "12345"); - assert_eq!(ad_response.site_id, "67890"); - assert_eq!(ad_response.page_id, "11111"); - assert_eq!(ad_response.format_id, "22222"); - assert_eq!(ad_response.advertiser_id, "33333"); - assert_eq!(ad_response.campaign_id, "44444"); - assert_eq!(ad_response.insertion_id, "55555"); - assert_eq!(ad_response.creative_id, "66666"); - assert_eq!( - ad_response.creative_url, - "https://cdn.example.com/creative/12345.jpg" - ); - - assert_eq!(ad_response.callbacks.len(), 3); - assert_eq!(ad_response.callbacks[0].callback_type, "impression"); - assert_eq!( - ad_response.callbacks[0].url, - "https://track.example.com/impression/12345" - ); - assert_eq!(ad_response.callbacks[1].callback_type, "click"); - assert_eq!(ad_response.callbacks[2].callback_type, "viewability"); - } - - #[test] - fn test_ad_response_empty_callbacks() { - let json_data = json!({ - "networkId": "12345", - "siteId": "67890", - "pageId": "11111", - "formatId": "22222", - "advertiserId": "33333", - "campaignId": "44444", - "insertionId": "55555", - "creativeId": "66666", - "creativeUrl": "https://cdn.example.com/creative/12345.jpg", - "callbacks": [] - }); - - let ad_response: AdResponse = serde_json::from_value(json_data) - .expect("should deserialize ad response with empty callbacks"); - assert_eq!(ad_response.callbacks.len(), 0); - } - - #[test] - fn test_ad_response_missing_field() { - // Missing required field should fail - let json_data = json!({ - "networkId": "12345", - "siteId": "67890", - // Missing pageId - "formatId": "22222", - "advertiserId": "33333", - "campaignId": "44444", - "insertionId": "55555", - "creativeId": "66666", - "creativeUrl": "https://cdn.example.com/creative/12345.jpg", - "callbacks": [] - }); - - let result: Result = serde_json::from_value(json_data); - result.unwrap_err(); - } - - #[test] - fn test_ad_response_case_sensitivity() { - // Test camelCase to snake_case conversion - let json_str = r#"{ - "networkId": "net123", - "siteId": "site456", - "pageId": "page789", - "formatId": "format000", - "advertiserId": "adv111", - "campaignId": "camp222", - "insertionId": "ins333", - "creativeId": "cre444", - "creativeUrl": "https://example.com/creative.png", - "callbacks": [] - }"#; - - let ad_response: AdResponse = - serde_json::from_str(json_str).expect("should deserialize ad response from str"); - assert_eq!(ad_response.network_id, "net123"); - assert_eq!(ad_response.site_id, "site456"); - assert_eq!(ad_response.page_id, "page789"); - assert_eq!(ad_response.format_id, "format000"); - } - - #[test] - fn test_callback_missing_field() { - let json_data = json!({ - "type": "impression" - // Missing url field - }); - - let result: Result = serde_json::from_value(json_data); - result.unwrap_err(); - } - - #[test] - fn test_callback_extra_fields() { - // Extra fields should be ignored - let json_data = json!({ - "type": "conversion", - "url": "https://example.com/track/conversion", - "extra": "ignored", - "another": 123 - }); - - let callback: Callback = serde_json::from_value(json_data) - .expect("should deserialize callback with extra fields"); - assert_eq!(callback.callback_type, "conversion"); - assert_eq!(callback.url, "https://example.com/track/conversion"); - } - - #[test] - fn test_ad_response_debug_format() { - let callback = Callback { - callback_type: "test".to_owned(), - url: "https://test.com".to_owned(), - }; - - let ad_response = AdResponse { - network_id: "123".to_owned(), - site_id: "456".to_owned(), - page_id: "789".to_owned(), - format_id: "000".to_owned(), - advertiser_id: "111".to_owned(), - campaign_id: "222".to_owned(), - insertion_id: "333".to_owned(), - creative_id: "444".to_owned(), - creative_url: "https://example.com/ad.jpg".to_owned(), - callbacks: vec![callback], - }; - - let debug_str = format!("{ad_response:?}"); - assert!(debug_str.contains("AdResponse")); - assert!(debug_str.contains("network_id")); - assert!(debug_str.contains("123")); - } - - #[test] - fn test_callback_debug_format() { - let callback = Callback { - callback_type: "debug_test".to_owned(), - url: "https://debug.test.com".to_owned(), - }; - - let debug_str = format!("{callback:?}"); - assert!(debug_str.contains("Callback")); - assert!(debug_str.contains("callback_type")); - assert!(debug_str.contains("debug_test")); - assert!(debug_str.contains("url")); - assert!(debug_str.contains("https://debug.test.com")); - } - - #[test] - fn test_various_callback_types() { - let callback_types = vec![ - "impression", - "click", - "viewability", - "conversion", - "engagement", - "complete", - "firstQuartile", - "midpoint", - "thirdQuartile", - ]; - - for cb_type in callback_types { - let json_data = json!({ - "type": cb_type, - "url": format!("https://example.com/track/{}", cb_type) - }); - - let callback: Callback = - serde_json::from_value(json_data).expect("should deserialize callback type"); - assert_eq!(callback.callback_type, cb_type); - assert_eq!(callback.url, format!("https://example.com/track/{cb_type}")); - } - } -} diff --git a/crates/trusted-server-core/src/module_secrets.rs b/crates/trusted-server-core/src/module_secrets.rs new file mode 100644 index 0000000000..b075d3fa98 --- /dev/null +++ b/crates/trusted-server-core/src/module_secrets.rs @@ -0,0 +1,307 @@ +//! The secret settings a module declares in its own table. +//! +//! A module's builder lists the settings in its table that hold the name of +//! a secret, each with a rule for whether the table as written puts the +//! setting to use. As the settings load, the ones not in use are cleared and +//! the others are looked up in the default secret store, and at deploy time +//! each one in use is checked to name a key. None of that needs to know +//! which module declared them. + +use std::borrow::Cow; + +use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; +use serde_json::{Map, Value}; + +use crate::integrations::IntegrationBuilder; +use crate::module_name::short_form; + +/// The section a module is selected in and the names its table can sit +/// under there: the name with the section's type folder left off, and the +/// name in full where the two differ. +fn written_forms(builder: &IntegrationBuilder) -> Option<(&'static str, Vec<&'static str>)> { + let name = builder.module_name()?; + let section = builder.section()?; + let short = short_form(section, name); + let mut forms = vec![short]; + if short != name { + forms.push(name); + } + Some((section, forms)) +} + +/// The form `section` selects the module by in a serialized configuration, +/// when it selects it. +fn selected_as<'a>(data: &Value, section: &str, forms: &[&'a str]) -> Option<&'a str> { + let section = data.get(section)?; + let named = |value: &Value| { + let written = value.as_str()?; + forms.iter().copied().find(|form| *form == written) + }; + section.get("module").and_then(named).or_else(|| { + section + .get("modules") + .and_then(Value::as_array) + .and_then(|modules| modules.iter().find_map(named)) + }) +} + +/// The table of a name as written in a section, found by the name's parts. +fn table_mut<'a>( + data: &'a mut Value, + section: &str, + written: &str, +) -> Option<&'a mut Map> { + let mut node = data.get_mut(section)?; + for part in written.split('.') { + node = node.get_mut(part)?; + } + node.as_object_mut() +} + +/// The value at `path` inside a module's table. +#[must_use] +pub(crate) fn value_at<'a>(table: &'a Map, path: &[&str]) -> Option<&'a Value> { + let (first, rest) = path.split_first()?; + let mut node = table.get(*first)?; + for part in rest { + node = node.get(*part)?; + } + Some(node) +} + +fn remove_at(table: &mut Map, path: &[&str]) { + let Some((leaf, parents)) = path.split_last() else { + return; + }; + let mut node = table; + for parent in parents { + let Some(next) = node.get_mut(*parent).and_then(Value::as_object_mut) else { + return; + }; + node = next; + } + node.remove(*leaf); +} + +/// Clears every declared secret setting that is not in use, in a serialized +/// configuration whose secrets have not been looked up yet. +/// +/// A setting is in use when its section selects the module and the module's +/// rule for the setting says its table uses it. A table the section does not +/// select is refused once the settings are deserialized, and clearing its +/// references first means that refusal is what an operator sees, rather than +/// a secret lookup failing. +pub(crate) fn clear_unused(data: &mut Value, builders: &[IntegrationBuilder]) { + for builder in builders { + let secrets = builder.secret_settings(); + if secrets.is_empty() { + continue; + } + let Some((section, forms)) = written_forms(builder) else { + continue; + }; + let selected = selected_as(data, section, &forms); + for form in &forms { + let Some(table) = table_mut(data, section, form) else { + continue; + }; + let runs = selected == Some(*form); + let unused = secrets + .iter() + .filter(|secret| !(runs && (secret.in_use)(table))) + .map(|secret| secret.path) + .collect::>(); + for path in unused { + remove_at(table, path); + } + } + } +} + +/// The secret leaves the builders' modules declare, at each name a module's +/// table can be written under. +/// +/// Every level is optional, so a module that is not configured, or a setting +/// [`clear_unused`] removed, is passed over. +#[must_use] +pub(crate) fn secret_fields(builders: &[IntegrationBuilder]) -> Vec { + let optional = |name: &'static str| SecretPathSegment::OptionalField(Cow::Borrowed(name)); + let mut fields = Vec::new(); + for builder in builders { + let Some((section, forms)) = written_forms(builder) else { + continue; + }; + for secret in builder.secret_settings() { + let Some((leaf, parents)) = secret.path.split_last() else { + continue; + }; + for form in &forms { + let mut path = vec![optional(section)]; + path.extend(form.split('.').map(optional)); + path.extend(parents.iter().copied().map(optional)); + path.push(SecretPathSegment::Field(Cow::Borrowed(*leaf))); + fields.push(SecretField { + kind: SecretKind::KeyInDefault, + optional: true, + path, + }); + } + } + } + fields +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::integrations::ModuleSecretSetting; + use crate::integrations::registry_test_support::{probe_registration, validate_nothing}; + + fn uses_key(table: &Map) -> bool { + table.get("lock").and_then(Value::as_bool) == Some(true) + } + + fn uses_spare(table: &Map) -> bool { + uses_key(table) + && table + .get("spare") + .and_then(|spare| spare.get("enabled")) + .and_then(Value::as_bool) + == Some(true) + } + + const SECRETS: &[ModuleSecretSetting] = &[ + ModuleSecretSetting { + path: &["key_name"], + in_use: uses_key, + }, + ModuleSecretSetting { + path: &["spare", "key_name"], + in_use: uses_spare, + }, + ]; + + fn builder() -> IntegrationBuilder { + IntegrationBuilder::new("probe", "seam-probe", probe_registration, validate_nothing) + .with_module_name("testing.probe") + .with_secret_settings(SECRETS) + } + + fn table(lock: bool, spare: bool) -> Value { + json!({ + "lock": lock, + "key_name": "main-key", + "spare": { "enabled": spare, "key_name": "spare-key" }, + }) + } + + #[test] + fn a_setting_in_use_is_kept_and_one_not_in_use_is_cleared() { + let mut data = json!({ "testing": { "modules": ["probe"], "probe": table(true, false) } }); + + clear_unused(&mut data, &[builder()]); + + assert_eq!( + data.pointer("/testing/probe/key_name"), + Some(&json!("main-key")), + "should keep the setting the table puts to use" + ); + assert_eq!( + data.pointer("/testing/probe/spare/key_name"), + None, + "should clear the setting the table does not put to use" + ); + assert_eq!( + data.pointer("/testing/probe/spare/enabled"), + Some(&json!(false)), + "should leave the rest of the table as written" + ); + } + + #[test] + fn every_setting_of_a_module_no_section_selects_is_cleared() { + let mut data = json!({ "testing": { "probe": table(true, true) } }); + + clear_unused(&mut data, &[builder()]); + + assert_eq!(data.pointer("/testing/probe/key_name"), None); + assert_eq!(data.pointer("/testing/probe/spare/key_name"), None); + } + + #[test] + fn a_module_selected_by_its_full_name_keeps_the_settings_in_the_table_at_that_name() { + let mut data = json!({ + "testing": { + "module": "testing.probe", + "testing": { "probe": table(true, true) }, + "probe": table(true, true), + } + }); + + clear_unused(&mut data, &[builder()]); + + assert_eq!( + data.pointer("/testing/testing/probe/key_name"), + Some(&json!("main-key")), + "should keep the settings of the table the selection names" + ); + assert_eq!( + data.pointer("/testing/testing/probe/spare/key_name"), + Some(&json!("spare-key")) + ); + assert_eq!( + data.pointer("/testing/probe/key_name"), + None, + "should clear the settings of a table under the name not selected" + ); + } + + #[test] + fn the_declared_leaves_are_listed_under_both_written_names() { + let paths = secret_fields(&[builder()]) + .iter() + .map(|field| (field.dotted_path(), field.optional)) + .collect::>(); + + assert_eq!( + paths, + vec![ + ("testing.probe.key_name".to_owned(), true), + ("testing.testing.probe.key_name".to_owned(), true), + ("testing.probe.spare.key_name".to_owned(), true), + ("testing.testing.probe.spare.key_name".to_owned(), true), + ], + "should list each declared leaf at the short name and at the full one" + ); + } + + #[test] + fn a_builder_that_declares_no_secret_setting_lists_none() { + let plain = + IntegrationBuilder::new("probe", "seam-probe", probe_registration, validate_nothing) + .with_module_name("testing.probe"); + let mut data = json!({ "testing": { "probe": table(false, false) } }); + let before = data.clone(); + + clear_unused(&mut data, &[plain]); + + assert_eq!(data, before, "should leave the table alone"); + assert!(secret_fields(&[plain]).is_empty()); + } + + #[test] + fn value_at_reads_a_nested_setting() { + let Value::Object(table) = table(true, true) else { + panic!("should build a table"); + }; + + assert_eq!(value_at(&table, &["key_name"]), Some(&json!("main-key"))); + assert_eq!( + value_at(&table, &["spare", "key_name"]), + Some(&json!("spare-key")) + ); + assert_eq!(value_at(&table, &["absent", "key_name"]), None); + } +} diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 4aded74882..7260d8b134 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -111,24 +111,6 @@ pub struct RegsExt { impl ToExt for RegsExt {} -#[derive(Debug, Serialize)] -pub struct RequestExt { - #[serde(skip_serializing_if = "Option::is_none")] - pub prebid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub trusted_server: Option, -} - -#[derive(Debug, Serialize, serde::Deserialize)] -pub struct PrebidExt { - #[serde(skip_serializing_if = "Option::is_none")] - pub debug: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub returnallbidstatus: Option, -} - -impl ToExt for RequestExt {} - #[derive(Debug, Serialize)] pub struct TrustedServerExt { /// Version of the signing protocol (e.g., "1.1") @@ -147,30 +129,6 @@ pub struct TrustedServerExt { pub ts: Option, } -#[derive(Debug, Serialize)] -pub struct ImpExt { - pub prebid: PrebidImpExt, -} - -impl ToExt for ImpExt {} - -#[derive(Debug, Default, Serialize)] -pub struct PrebidImpExt { - #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] - pub bidder: std::collections::HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub storedrequest: Option, -} - -/// 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. -#[derive(Debug, Serialize)] -pub struct ImpStoredRequest { - pub id: String, -} - #[derive(Debug, Serialize)] pub struct BidExt<'a> { pub trusted_server: BidTrustedServerExt<'a>, diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 3bbb8baff4..fbd7b7a2dd 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -5,8 +5,11 @@ //! signals and the country it resolves to, and refuses to run the Edge Cookie //! module when its required permissions are not set. The device and geo //! modules declare their requirements through the same method, and the -//! built-in ones require none; gating their execution on that declaration is -//! follow-up work. +//! built-in and host ones require none. A device module supplied by an +//! integration module that declares any permission is refused at startup, +//! because no per-request device gate exists yet to honor it. A geo +//! declaration is not consulted, because geo runs before the permissions are +//! assembled and supplies the country they depend on. //! //! The vocabulary is the IAB Privacy Taxonomy Data Uses, mapped from the IAB TCF //! Europe purposes and used **only** as a technical identifier for a permission. diff --git a/crates/trusted-server-core/src/platform/geo.rs b/crates/trusted-server-core/src/platform/geo.rs new file mode 100644 index 0000000000..c8077215dd --- /dev/null +++ b/crates/trusted-server-core/src/platform/geo.rs @@ -0,0 +1,30 @@ +use std::net::IpAddr; + +use error_stack::Report; + +use super::{GeoInfo, PlatformError, PlatformGeo, RuntimeServices}; + +/// A geo module that resolves nothing. +/// +/// Installed when the `[geo] module` selector is unset, and when it is set to +/// `"none"`, which spells the same choice explicitly. A client IP is then never +/// sent to any host geo service, so a default deployment is not tied to any host +/// geo capability. Every geo consumer already treats [`GeoInfo`] as optional, so +/// a `None` result degrades gracefully: the permission baseline falls back to +/// the top of the `permissions.yaml` rules tree, the auction omits geo, and so on. +/// +/// Adapter crates should use this type rather than defining their own stub so +/// the behavior is the same on every platform. +#[derive(Debug, Default, Clone, Copy)] +pub struct DisabledGeo; + +#[async_trait::async_trait(?Send)] +impl PlatformGeo for DisabledGeo { + async fn lookup( + &self, + _client_ip: Option, + _services: &RuntimeServices, + ) -> Result, Report> { + Ok(None) + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index e4a836f47e..933754993b 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -37,14 +37,22 @@ use std::time::Duration; mod backend_naming; mod error; +mod geo; mod http; mod image_optimizer; mod kv; mod policy_source; mod template_assembly; mod template_cache; -#[cfg(test)] -pub(crate) mod test_support; +#[cfg(any(test, feature = "test-utils"))] +#[allow( + clippy::must_use_candidate, + clippy::missing_panics_doc, + clippy::missing_errors_doc, + clippy::new_without_default, + reason = "test doubles, offered to other crates' tests under the test-utils feature" +)] +pub mod test_support; mod traits; mod types; @@ -54,6 +62,7 @@ pub use backend_naming::{ }; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; +pub use geo::DisabledGeo; pub use http::{ PlatformCacheIntent, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, UnavailableHttpClient, @@ -84,29 +93,10 @@ pub use types::{ /// Default first-byte timeout for platform backends. pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); -use std::net::IpAddr; use std::sync::Arc; -use error_stack::Report; - use crate::settings::Settings; -/// A geo module that resolves nothing. -/// -/// Installed when no geo module is selected (`"none"` spells the same -/// choice explicitly), so a client IP is never sent to any host geo service -/// and a default deployment is not tied to any host geo capability. Every geo -/// consumer already treats [`GeoInfo`] as optional, so a `None` result -/// degrades gracefully (the permission baseline falls back to the top of the -/// `permissions.yaml` rules tree, the auction omits geo, and so on). -pub struct DisabledGeo; - -impl PlatformGeo for DisabledGeo { - fn lookup(&self, _client_ip: Option) -> Result, Report> { - Ok(None) - } -} - /// Selects the geo module named by the `[geo] module` selector. /// /// Returns [`DisabledGeo`] when no module is selected, so a default @@ -114,9 +104,15 @@ impl PlatformGeo for DisabledGeo { /// the top of the `permissions.yaml` rules tree. `module = "none"` spells the /// same choice explicitly. The host platform's own geo lookup is opt-in: /// `module = "platform"` returns `host_default`, which the adapter passes -/// as its platform geo implementation. A selected-but-unknown module is -/// rejected at startup by -/// [`GeoConfig::validate_module_selection`](crate::settings::GeoConfig::validate_module_selection). +/// as its platform geo implementation. +/// +/// Any other value names an integration module that declares a geo module. +/// This function returns [`DisabledGeo`] for one, because it cannot see the +/// registry, and the adapter then replaces it with the module's module from +/// `IntegrationRegistry::geo_module`. So the value returned here is the base +/// the adapter starts from, not necessarily what serves the request. A selector +/// naming a module that supplies no geo module is rejected when the registry +/// is built, which is the only layer that can tell. #[must_use] pub fn build_geo_module( settings: &Settings, @@ -218,18 +214,19 @@ mod tests { ); } - #[test] - fn runtime_services_geo_lookup_returns_none_for_no_ip() { + #[tokio::test] + async fn runtime_services_geo_lookup_returns_none_for_no_ip() { let services = noop_services(); let result = services .geo() - .lookup(services.client_info().client_ip) + .lookup(services.client_info().client_ip, &noop_services()) + .await .expect("should not fail for noop geo with no ip"); assert!(result.is_none(), "should return None when no IP is present"); } - #[test] - fn build_geo_module_defaults_to_no_geo() { + #[tokio::test] + async fn build_geo_module_defaults_to_no_geo() { let settings = Settings::default(); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); @@ -239,7 +236,11 @@ mod tests { ); assert!( selected - .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) + .lookup( + Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + &noop_services() + ) + .await .expect("disabled geo lookup should not fail") .is_none(), "the default geo module should resolve nothing" diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 334de7eb6b..8ded8a577e 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -11,13 +11,11 @@ use rand::rngs::OsRng; use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformCacheIntent, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, - StoreName, + PlatformImageOptimizerOptions, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, + PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; -use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; -pub(crate) struct NoopConfigStore; +pub struct NoopConfigStore; impl PlatformConfigStore for NoopConfigStore { fn get(&self, _store_name: &StoreName, _key: &str) -> Result> { @@ -38,7 +36,7 @@ impl PlatformConfigStore for NoopConfigStore { } } -pub(crate) struct NoopSecretStore; +pub struct NoopSecretStore; impl PlatformSecretStore for NoopSecretStore { fn get_bytes( @@ -63,12 +61,12 @@ impl PlatformSecretStore for NoopSecretStore { } } -pub(crate) struct HashMapConfigStore { +pub struct HashMapConfigStore { data: HashMap, } impl HashMapConfigStore { - pub(crate) fn new(data: HashMap) -> Self { + pub fn new(data: HashMap) -> Self { Self { data } } } @@ -95,12 +93,12 @@ impl PlatformConfigStore for HashMapConfigStore { } } -pub(crate) struct HashMapSecretStore { +pub struct HashMapSecretStore { data: HashMap>, } impl HashMapSecretStore { - pub(crate) fn new(data: HashMap>) -> Self { + pub fn new(data: HashMap>) -> Self { Self { data } } } @@ -131,7 +129,7 @@ impl PlatformSecretStore for HashMapSecretStore { } } -pub(crate) struct NoopBackend; +pub struct NoopBackend; impl PlatformBackend for NoopBackend { fn naming_policy(&self) -> super::BackendNamingPolicy { @@ -147,7 +145,7 @@ impl PlatformBackend for NoopBackend { } } -pub(crate) struct NoopHttpClient; +pub struct NoopHttpClient; // ?Send matches PlatformHttpClient. Body wraps LocalBoxStream which is !Send // by design; see http.rs for the full rationale. @@ -181,7 +179,7 @@ impl PlatformHttpClient for NoopHttpClient { /// Test stub for [`PlatformBackend`] that returns `"stub-backend"` for any /// spec, allowing callers to proceed past backend registration. -pub(crate) struct StubBackend; +pub struct StubBackend; impl PlatformBackend for StubBackend { fn naming_policy(&self) -> super::BackendNamingPolicy { @@ -197,6 +195,82 @@ impl PlatformBackend for StubBackend { } } +// --------------------------------------------------------------------------- +// NamingBackend +// --------------------------------------------------------------------------- + +/// Test stub for [`PlatformBackend`] that names a backend the way the given +/// policy does, so two demand sources get two names, and records what it was +/// asked. +pub struct NamingBackend { + pub policy: super::BackendNamingPolicy, + /// How many names were predicted. + pub predicted: std::sync::atomic::AtomicUsize, + /// How many backends were registered or refused. + pub ensured: std::sync::atomic::AtomicUsize, + /// The specification of every backend registered, in order. + pub specs: Mutex>, + /// The discriminators whose registration fails. + pub fail_ensure_for: Mutex>, +} + +impl NamingBackend { + pub fn new(policy: super::BackendNamingPolicy) -> Self { + Self { + policy, + predicted: std::sync::atomic::AtomicUsize::new(0), + ensured: std::sync::atomic::AtomicUsize::new(0), + specs: Mutex::new(Vec::new()), + fail_ensure_for: Mutex::new(std::collections::HashSet::new()), + } + } + + /// Makes registration fail for the source `provider_id`. + pub 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) -> super::BackendNamingPolicy { + self.policy + } + + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + self.predicted + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.name(spec) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.ensured + .fetch_add(1, std::sync::atomic::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) + } +} + // --------------------------------------------------------------------------- // StubHttpClient // --------------------------------------------------------------------------- @@ -226,7 +300,7 @@ struct StubPendingResponse { const MAX_RECORDED_BODY_BYTES: usize = 64 * 1024 * 1024; type RecordedHeaderBytes = Vec)>>; -pub(crate) struct StubHttpClient { +pub struct StubHttpClient { calls: Mutex>, responses: Mutex>, // Headers captured per send call, stored as (name, value) string pairs. @@ -302,7 +376,7 @@ impl StubHttpClient { } /// Make `has_enforceable_total_request_deadline()` report the given value. - pub(crate) fn set_enforceable_total_request_deadline(&self, supported: bool) { + pub fn set_enforceable_total_request_deadline(&self, supported: bool) { self.enforceable_total_request_deadline .store(supported, std::sync::atomic::Ordering::Relaxed); } @@ -370,7 +444,7 @@ impl StubHttpClient { } /// Make the next `select()` complete successfully before a later queued error. - pub(crate) fn push_select_success(&self) { + pub fn push_select_success(&self) { self.select_errors .lock() .expect("should lock select_errors") @@ -379,7 +453,7 @@ impl StubHttpClient { /// Override backend metadata on the next pending handle returned by /// [`Self::send_async`]. `None` removes the metadata entirely. - pub(crate) fn push_pending_backend_name_override(&self, backend_name: Option<&str>) { + pub fn push_pending_backend_name_override(&self, backend_name: Option<&str>) { self.pending_backend_name_overrides .lock() .expect("should lock pending backend name overrides") @@ -391,7 +465,7 @@ impl StubHttpClient { /// This is test-only timing control for deadline behavior. It deliberately /// uses a caller-selected, generous contrast with the tested budget rather /// than relying on scheduler races. - pub(crate) fn push_select_delay(&self, delay: Duration) { + pub fn push_select_delay(&self, delay: Duration) { self.select_delays .lock() .expect("should lock select_delays") @@ -399,7 +473,7 @@ impl StubHttpClient { } /// Queue a wall-clock delay before the next direct pending-request `wait()`. - pub(crate) fn push_wait_delay(&self, delay: Duration) { + pub fn push_wait_delay(&self, delay: Duration) { self.wait_delays .lock() .expect("should lock wait_delays") @@ -432,7 +506,7 @@ impl StubHttpClient { /// Return raw request header values captured per request, in order. /// /// Unlike [`Self::recorded_request_headers`], this includes malformed bytes. - pub(crate) fn recorded_request_header_bytes(&self) -> Vec)>> { + pub fn recorded_request_header_bytes(&self) -> Vec)>> { self.request_header_bytes .lock() .expect("should lock request_header_bytes") @@ -448,7 +522,7 @@ impl StubHttpClient { } /// Return the cache intent captured per `send` or `send_async` call, in order. - pub(crate) fn recorded_cache_intents(&self) -> Vec { + pub fn recorded_cache_intents(&self) -> Vec { self.cache_intents .lock() .expect("should lock cache intents") @@ -857,10 +931,15 @@ fn build_stub_pending_response( builder.body(body).change_context(PlatformError::HttpClient) } -pub(crate) struct NoopGeo; +pub struct NoopGeo; +#[async_trait::async_trait(?Send)] impl PlatformGeo for NoopGeo { - fn lookup(&self, _client_ip: Option) -> Result, Report> { + async fn lookup( + &self, + _client_ip: Option, + _services: &crate::platform::RuntimeServices, + ) -> Result, Report> { Ok(None) } } @@ -875,7 +954,7 @@ pub(crate) fn test_services_builder() -> super::RuntimeServicesBuilder { /// /// Use this when a test exercises code that reads from config AND secret stores, /// such as `request_signing::signing` and `request_signing::rotation`. -pub(crate) fn build_services_with_config_and_secret( +pub fn build_services_with_config_and_secret( config_store: impl PlatformConfigStore + 'static, secret_store: impl PlatformSecretStore + 'static, ) -> RuntimeServices { @@ -890,7 +969,7 @@ pub(crate) fn build_services_with_config_and_secret( .build() } -pub(crate) fn build_services_with_config_and_secret_and_client_ip( +pub fn build_services_with_config_and_secret_and_client_ip( config_store: impl PlatformConfigStore + 'static, secret_store: impl PlatformSecretStore + 'static, client_ip: IpAddr, @@ -909,7 +988,7 @@ pub(crate) fn build_services_with_config_and_secret_and_client_ip( .build() } -pub(crate) fn build_request_signing_services() -> RuntimeServices { +pub fn build_request_signing_services() -> RuntimeServices { let signing_key = SigningKey::generate(&mut OsRng); let key_b64 = general_purpose::STANDARD.encode(signing_key.as_bytes()); let x_b64 = general_purpose::URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes()); @@ -929,7 +1008,7 @@ pub(crate) fn build_request_signing_services() -> RuntimeServices { ) } -pub(crate) fn build_services_with_config( +pub fn build_services_with_config( config_store: impl PlatformConfigStore + 'static, ) -> RuntimeServices { test_services_builder() @@ -943,7 +1022,7 @@ pub(crate) fn build_services_with_config( .build() } -pub(crate) fn noop_services() -> RuntimeServices { +pub fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } @@ -953,7 +1032,7 @@ pub(crate) fn noop_services() -> RuntimeServices { /// /// This is the only way to reach the lookup-failure path, because the seam is /// what turns an `Err` into the requires-signal floor. -pub(crate) fn build_services_with_geo(geo: Arc) -> RuntimeServices { +pub fn build_services_with_geo(geo: Arc) -> RuntimeServices { test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) @@ -969,7 +1048,7 @@ pub(crate) fn build_services_with_geo(geo: Arc) -> RuntimeServi /// way a composition root threads the module it resolved, so a test can /// exercise the seam a vendor module reaches core through and check that the /// request path reuses that instance. -pub(crate) fn noop_services_with_ec_module( +pub fn noop_services_with_ec_module( ec_module: Arc, ) -> RuntimeServices { // A fixed client IP, so a module that reads one (the built-in HMAC @@ -983,10 +1062,64 @@ pub(crate) fn noop_services_with_ec_module( /// Build a [`RuntimeServices`] with an injected Edge Cookie module and no /// client IP, modeling a host that cannot determine one. /// -/// Whether that matters is the module's decision, so this exists to test both -/// answers: a module reading other evidence still creates an identifier, and -/// one that needs the IP refuses. -pub(crate) fn noop_services_with_ec_module_without_client_ip( +/// Whether that matters is the module's decision, so this exists to test +/// both answers: a module reading other evidence still creates an +/// identifier, and one that needs the IP refuses. +/// A config store that answers one known key, so a test can prove a module +/// reached the config store it was handed rather than a value it already held. +#[derive(Debug)] +pub struct FixedConfigStore { + pub store: &'static str, + pub key: &'static str, + pub value: &'static str, +} + +impl PlatformConfigStore for FixedConfigStore { + fn get(&self, store_name: &StoreName, key: &str) -> Result> { + if store_name.as_ref() == self.store && key == self.key { + Ok(self.value.to_owned()) + } else { + Err(Report::new(PlatformError::ConfigStore)) + } + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +/// Build a [`RuntimeServices`] carrying both an Edge Cookie module and a +/// config store the module is expected to read through, so a test can prove +/// the services reaching the module are the ones the caller supplied. +pub fn services_with_ec_module_and_config_store( + ec_module: Arc, + config_store: Arc, +) -> RuntimeServices { + test_services_builder() + .config_store(config_store) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + ..ClientInfo::default() + }) + .resolved_ec_module(ec_module) + .build() +} + +pub fn noop_services_with_ec_module_without_client_ip( ec_module: Arc, ) -> RuntimeServices { noop_services_with_ec_module_and_ip(ec_module, None) @@ -1014,7 +1147,7 @@ fn noop_services_with_ec_module_and_ip( /// Build a [`RuntimeServices`] whose auction telemetry sink is the supplied /// recording (or otherwise custom) sink, so tests can assert which terminal /// auction events were emitted. -pub(crate) fn noop_services_with_telemetry_sink( +pub fn noop_services_with_telemetry_sink( auction_telemetry_sink: Arc, ) -> RuntimeServices { test_services_builder() @@ -1036,14 +1169,14 @@ pub(crate) fn noop_services_with_telemetry_sink( /// both make HTTP calls and resolve backends don't need two separate service /// setups. If your test must verify that a missing backend returns an error, /// use [`noop_services`] directly. -pub(crate) fn build_services_with_http_client( +pub fn build_services_with_http_client( http_client: Arc, ) -> RuntimeServices { build_services_with_secret_and_http_client(NoopSecretStore, http_client) } /// Build test services that dispatch HTTP requests with an attested client IP. -pub(crate) fn build_services_with_http_client_and_client_ip( +pub fn build_services_with_http_client_and_client_ip( http_client: Arc, client_ip: IpAddr, ) -> RuntimeServices { @@ -1061,7 +1194,7 @@ pub(crate) fn build_services_with_http_client_and_client_ip( .build() } -pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { +pub fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) @@ -1087,7 +1220,7 @@ pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { dead_code, reason = "retained for target-specific transport-timeout tests" )] -pub(crate) fn build_services_with_backend_and_http_client( +pub fn build_services_with_backend_and_http_client( backend: Arc, http_client: Arc, ) -> RuntimeServices { @@ -1108,7 +1241,7 @@ pub(crate) fn build_services_with_backend_and_http_client( } /// Build a [`RuntimeServices`] with a custom secret store, [`StubBackend`], and HTTP client. -pub(crate) fn build_services_with_config_secret_and_http_client( +pub fn build_services_with_config_secret_and_http_client( config_store: impl PlatformConfigStore + 'static, secret_store: impl PlatformSecretStore + 'static, http_client: Arc, @@ -1124,7 +1257,7 @@ pub(crate) fn build_services_with_config_secret_and_http_client( .build() } -pub(crate) fn build_services_with_secret_http_client_and_client_ip( +pub fn build_services_with_secret_http_client_and_client_ip( secret_store: impl PlatformSecretStore + 'static, http_client: Arc, client_ip: Option, @@ -1146,7 +1279,7 @@ pub(crate) fn build_services_with_secret_http_client_and_client_ip( } /// Build test services with a custom secret store and the standard test config store. -pub(crate) fn build_services_with_secret_and_http_client( +pub fn build_services_with_secret_and_http_client( secret_store: impl PlatformSecretStore + 'static, http_client: Arc, ) -> RuntimeServices { @@ -1155,7 +1288,8 @@ pub(crate) fn build_services_with_secret_and_http_client( #[cfg(test)] mod tests { - use crate::platform::DEFAULT_FIRST_BYTE_TIMEOUT; + use crate::platform::{DEFAULT_FIRST_BYTE_TIMEOUT, PlatformImageOptimizerParams}; + use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; use edgezero_core::body::Body; use edgezero_core::http::request_builder; diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index ab2ef16f33..47d7d9cf57 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -2,7 +2,10 @@ use std::net::IpAddr; use error_stack::Report; -use super::{BackendNamingPolicy, GeoInfo, PlatformBackendSpec, PlatformError, StoreId, StoreName}; +use super::{ + BackendNamingPolicy, GeoInfo, PlatformBackendSpec, PlatformError, RuntimeServices, StoreId, + StoreName, +}; /// Synchronous, object-safe access to a key-value config store. /// @@ -136,7 +139,19 @@ pub trait PlatformBackend: Send + Sync { } } -/// Synchronous, object-safe geo lookup. +/// Object-safe geo lookup. +/// +/// Asynchronous because a geo module may reach a backend, a key-value store +/// or a secret to resolve a location, and a module that cannot make those +/// calls cannot be written at all. A module that resolves from data already +/// in hand still declares an async method and returns immediately. +/// +/// Uses `#[async_trait(?Send)]` for the same reason as +/// [`PlatformHttpClient`](super::PlatformHttpClient): the trait object stays +/// `Send + Sync` so it can be shared and run multi-threaded, while the future +/// it returns is pinned to one thread because the host SDKs produce `!Send` +/// futures on wasm32. +#[async_trait::async_trait(?Send)] pub trait PlatformGeo: Send + Sync { /// Look up geographic information for the given client IP address. /// @@ -151,7 +166,11 @@ pub trait PlatformGeo: Send + Sync { /// /// Returns [`PlatformError::Geo`] when the platform geo lookup fails /// unexpectedly. Returns `Ok(None)` when no data is available for the IP. - fn lookup(&self, client_ip: Option) -> Result, Report>; + async fn lookup( + &self, + client_ip: Option, + services: &RuntimeServices, + ) -> Result, Report>; /// The permissions this module's data use requires. /// diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 10387b31ad..1e004a29d9 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -9,6 +9,7 @@ use super::{ PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; +use crate::ec::device::DeviceModule; use crate::ec::module::EdgeCookieModule; use crate::evidence::HostSignals; use crate::permission_signal::PermissionSignalModule; @@ -204,6 +205,7 @@ pub struct RuntimeServices { /// path resolves the selection itself, which is what a deployment that /// selects no module, the Axum adapter, and the core tests all do. pub(crate) resolved_ec_module: Option>, + pub(crate) device_module: Option>, /// The permission signal modules this deployment runs, in the order /// they are asked, selected at the composition root from the scheme /// crates the adapter links. Empty when the adapter offers none, in which @@ -335,6 +337,13 @@ impl RuntimeServices { &self.permission_signal_modules } + /// The device module a module supplied, when `[device] module` selected + /// one. `None` leaves core's own device classification in place. + #[must_use] + pub fn device_module(&self) -> Option> { + self.device_module.clone() + } + /// The permission policy the request's domain loaded. #[must_use] pub fn permission_policy(&self) -> &Arc { @@ -380,6 +389,61 @@ impl RuntimeServices { } } + /// Returns a clone of this instance with the geo module replaced by + /// `geo`. + /// + /// Adapters use this to apply the module-supplied geo module selected by + /// `[geo] module`, so a vendor module can resolve location in place of + /// the host's own lookup without the rest of the runtime services graph + /// being rebuilt. + #[must_use] + pub fn with_geo(self, geo: Arc) -> Self { + Self { geo, ..self } + } + + /// Returns a clone of this instance with the per-request client metadata + /// replaced. + /// + /// A response-side path that builds the rest of the services graph once and + /// varies only the client metadata per request uses this to apply the real + /// client data without rebuilding the graph. A module called from such a + /// path still needs that data. + #[must_use] + pub fn with_client_info(self, client_info: ClientInfo) -> Self { + Self { + client_info, + ..self + } + } + + /// Returns a clone of this instance with the Edge Cookie module replaced. + /// + /// Adapters use this to apply the module-supplied module selected by + /// `[ec] module`, the same way they apply a module's geo module, so a + /// vendor declares identity on its registration rather than through a + /// second extension mechanism. The registry already matched the selector + /// against the registered modules, so the module's module is the + /// resolved answer and lands on the one seam the request path reads. + #[must_use] + pub fn with_ec_module(self, ec_module: Arc) -> Self { + Self { + resolved_ec_module: Some(ec_module), + ..self + } + } + + /// Returns a clone of this instance with the device module replaced. + /// + /// Adapters use this to apply the module-supplied module selected by + /// `[device] module`. + #[must_use] + pub fn with_device_module(self, device_module: Arc) -> Self { + Self { + device_module: Some(device_module), + ..self + } + } + /// Returns a clone of this instance carrying the permission policy the /// request's domain loaded. /// @@ -440,6 +504,7 @@ pub struct RuntimeServicesBuilder { client_info: Option, host_signals: Option>, resolved_ec_module: Option>, + device_module: Option>, permission_signal_modules: Arc<[Arc]>, permission_policy: Option>, } @@ -459,6 +524,7 @@ impl RuntimeServicesBuilder { client_info: None, host_signals: None, resolved_ec_module: None, + device_module: None, permission_signal_modules: Arc::default(), permission_policy: None, } @@ -632,6 +698,7 @@ impl RuntimeServicesBuilder { .expect("should set client_info before building RuntimeServices"), host_signals: self.host_signals, resolved_ec_module: self.resolved_ec_module, + device_module: self.device_module, permission_signal_modules: self.permission_signal_modules, permission_policy: self .permission_policy diff --git a/crates/trusted-server-core/src/provider_table.rs b/crates/trusted-server-core/src/provider_table.rs new file mode 100644 index 0000000000..9715accdc7 --- /dev/null +++ b/crates/trusted-server-core/src/provider_table.rs @@ -0,0 +1,886 @@ +//! The table the auction's `[demand]` and `[ad-server]` share. +//! +//! Each is one top-level table. Its `modules` key, or `module` where one +//! runs, selects what runs, and every other key is one named provider's +//! settings table: +//! +//! ```toml +//! [demand] +//! modules = ["pbs_demo"] +//! +//! [demand.pbs_demo] +//! implementation = "auction.prebid-server" +//! endpoint = "https://prebid.example.com/openrtb2/auction" +//! ``` +//! +//! A table's `implementation` line names the implementation it configures by +//! its module path, which lets several providers share one implementation +//! under names of their own. Without the line the table's name stands for +//! the implementation, which only a section named by the implementation's +//! type can match, as `mock` in `[ad-server]` is `ad-server.mock`. A provider +//! with nothing to set needs no table. [`ProviderTable::validate`] checks the +//! rules that hold for every type, and the code that reads a type checks what +//! only that type knows, such as which implementations exist. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeMap as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Value}; + +/// The line naming the implementation a table configures when the table's +/// name is a label rather than the implementation. +pub const IMPLEMENTATION_KEY: &str = "implementation"; + +/// The longest provider name, in bytes. +const MAX_NAME_BYTES: usize = 63; + +/// A provider type whose `modules` names any number of providers. +pub type ProviderList = ProviderTable>; + +/// A provider type whose `module` names at most one provider. +pub type ProviderChoice = ProviderTable>; + +/// How a type's selection is written. +pub trait ProviderSelection: Default + Clone + PartialEq + fmt::Debug { + /// The key the selection is written under, `module` for a type that runs + /// one and `modules` for a type that runs several. + const KEY: &'static str; + + /// The selected names, in the order they were written. + fn names(&self) -> Vec<&str>; + + /// Reads the selection. + /// + /// # Errors + /// + /// Returns a message when the value has the wrong shape for this type. + fn from_value(value: Value) -> Result; + + /// Writes the selection, or `None` when nothing is selected. + fn to_value(&self) -> Option; +} + +impl ProviderSelection for Vec { + const KEY: &'static str = "modules"; + + fn names(&self) -> Vec<&str> { + self.iter().map(String::as_str).collect() + } + + fn from_value(value: Value) -> Result { + match value { + Value::Array(items) => items + .into_iter() + .map(|item| match item { + Value::String(name) => Ok(name), + other => Err(format!("`modules` must list names, found `{other}`")), + }) + .collect(), + Value::String(name) => Err(format!( + "`modules` is a list for this type, so write [\"{name}\"]" + )), + other => Err(format!( + "`modules` must be a list of names, found `{other}`" + )), + } + } + + fn to_value(&self) -> Option { + (!self.is_empty()).then(|| Value::Array(self.iter().cloned().map(Value::String).collect())) + } +} + +impl ProviderSelection for Option { + const KEY: &'static str = "module"; + + fn names(&self) -> Vec<&str> { + self.iter().map(String::as_str).collect() + } + + fn from_value(value: Value) -> Result { + match value { + Value::String(name) => Ok(Some(name)), + Value::Array(_) => { + Err("`module` names one provider for this type, not a list".to_owned()) + } + other => Err(format!("`module` must be a name, found `{other}`")), + } + } + + fn to_value(&self) -> Option { + self.clone().map(Value::String) + } +} + +/// One provider type's table. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ProviderTable { + selected: S, + tables: BTreeMap>, +} + +impl ProviderTable { + /// Builds a table from its selection and its named settings tables. + #[must_use] + pub fn new(selected: S, tables: BTreeMap>) -> Self { + Self { selected, tables } + } + + /// The selected provider names, in the order they were written. + #[must_use] + pub fn selected(&self) -> Vec<&str> { + self.selected.names() + } + + /// Whether no provider is selected. + #[must_use] + pub fn is_empty(&self) -> bool { + self.selected.names().is_empty() + } + + /// Whether the table sets nothing at all, neither a selection nor any named + /// table, so a serialized configuration can leave it out. + #[must_use] + pub fn is_unset(&self) -> bool { + self.is_empty() && self.tables.is_empty() + } + + /// Every named settings table, selected or not. + #[must_use] + pub fn tables(&self) -> &BTreeMap> { + &self.tables + } + + /// The implementation a selected name uses, being its `implementation` + /// line or else the name itself. + #[must_use] + pub fn implementation_of<'a>(&'a self, name: &'a str) -> &'a str { + self.tables + .get(name) + .and_then(|table| table.get(IMPLEMENTATION_KEY)) + .and_then(Value::as_str) + .unwrap_or(name) + } + + /// A name's settings, without its `implementation` line. A name with no + /// table has no settings. + #[must_use] + pub fn settings_of(&self, name: &str) -> Map { + let mut settings = self.tables.get(name).cloned().unwrap_or_default(); + settings.remove(IMPLEMENTATION_KEY); + settings + } + + /// Checks the rules every provider type shares, naming the type in each + /// message. + /// + /// # Errors + /// + /// Returns a message for a name that is not `snake_case`, a name selected + /// twice, an `implementation` line that is not a module name, or a table + /// the selection does not name. + pub fn validate(&self, type_name: &str) -> Result<(), String> { + let selected = self.selected.names(); + for (position, name) in selected.iter().enumerate() { + check_name(type_name, "module name", name)?; + if selected[..position].contains(name) { + return Err(format!( + "[{type_name}] {} names `{name}` more than once", + S::KEY + )); + } + } + for (name, table) in &self.tables { + check_name(type_name, "table name", name)?; + if let Some(implementation) = table.get(IMPLEMENTATION_KEY) { + let valid = implementation + .as_str() + .is_some_and(crate::module_name::is_valid); + if !valid { + return Err(format!( + "[{type_name}.{name}] implementation must be a module name, its \ + crate's parts joined by `.`, each in lower case letters, digits, \ + `_` or `-`, such as `auction.example`" + )); + } + } + if !selected.contains(&name.as_str()) { + return Err(format!( + "[{type_name}.{name}] is configured, but [{type_name}] {key} does not select `{name}`. Add it to {key}, or remove the table", + key = S::KEY + )); + } + } + Ok(()) + } +} + +/// Checks that a module's name within its type is `snake_case`. +fn check_name(type_name: &str, what: &str, name: &str) -> Result<(), String> { + let mut bytes = name.bytes(); + let valid = name.len() <= MAX_NAME_BYTES + && bytes.next().is_some_and(|first| first.is_ascii_lowercase()) + && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'); + if valid { + Ok(()) + } else { + Err(format!( + "[{type_name}] {what} `{name}` must be snake_case: a lowercase letter followed by up to {} lowercase letters, digits or underscores", + MAX_NAME_BYTES - 1 + )) + } +} + +impl<'de, S: ProviderSelection> Deserialize<'de> for ProviderTable { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct TableVisitor(core::marker::PhantomData); + + impl<'de, S: ProviderSelection> Visitor<'de> for TableVisitor { + type Value = ProviderTable; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a provider table with its selection key and named tables") + } + + fn visit_map(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 5e4fcd1b00..e7160087a3 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -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. @@ -1270,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(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 36e34cb244..23c326d32c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -51,7 +51,7 @@ 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, @@ -72,7 +72,7 @@ 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, @@ -530,8 +530,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 /// @@ -552,28 +555,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, + )); } } @@ -609,21 +612,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. @@ -641,9 +645,8 @@ struct ProcessResponseParams<'a> { 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`]. @@ -685,8 +688,7 @@ impl PublisherBodyProcessor { 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 @@ -770,8 +772,7 @@ fn process_response_streaming( 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, @@ -1330,8 +1331,9 @@ struct HtmlStreamProcessorParams<'a> { 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 @@ -1343,26 +1345,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(), } } @@ -1540,7 +1538,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. @@ -1552,10 +1550,9 @@ fn create_html_stream_processor( 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)) } @@ -1754,11 +1751,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 @@ -2099,7 +2093,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 @@ -2109,20 +2103,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 @@ -2213,8 +2217,9 @@ fn build_template_assembly_params( 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(), } } @@ -2815,6 +2820,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); @@ -2838,14 +2850,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) { @@ -2971,8 +2983,7 @@ pub fn stream_publisher_body( 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(), }; @@ -3077,8 +3088,7 @@ pub async fn stream_publisher_body_async( 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 @@ -3127,14 +3137,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()) @@ -3213,7 +3223,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>( @@ -3246,7 +3256,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") @@ -3259,7 +3273,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) @@ -3437,11 +3455,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 `
"#; + let html = br#"
"#; for finalizer in [Finalizer::Streaming, Finalizer::Buffered] { let mut templates = Vec::new(); for request in [navigation_request(), prefetch_navigation_request()] { - // Independent fills force distinct per-request RSC namespaces. + // Independent fills force distinct per-document namespaces. let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let services = services(Arc::clone(&stub), Arc::clone(&cache)); @@ -12980,11 +13198,7 @@ mod tests { let _ = body_of(response).await; let entries = cache.entries.lock().expect("should lock stored templates"); - assert_eq!( - entries.len(), - 1, - "should store a Next.js template on every fill" - ); + assert_eq!(entries.len(), 1, "should store a template on every fill"); let template = entries .values() .next() @@ -12994,14 +13208,14 @@ mod tests { let text = core::str::from_utf8(&template).expect("should store UTF-8 HTML"); assert!( text.contains("ts.example.com/page"), - "should rewrite the RSC URL" + "should rewrite the URL in the payload" ); assert!( text.contains(AD_ASSEMBLY_SEAM), "should store before per-reader assembly" ); for marker in [ - "__ts_rsc_", + crate::integrations::registry_test_support::payload_fixture::PLACEHOLDER_PREFIX, ".adSlots", ".bids=", "gpt-diagnostics", @@ -13016,7 +13230,7 @@ mod tests { } assert_eq!( templates[0], templates[1], - "should store identical bytes across request shapes and RSC namespaces" + "should store identical bytes across request shapes and per-document namespaces" ); } } @@ -13038,7 +13252,9 @@ mod tests { let services = services(Arc::clone(&stub), Arc::clone(&cache)); queue_shareable_html(&stub); - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let orchestrator = Arc::new(AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + )); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); let consent = crate::consent::ConsentContext { @@ -13047,7 +13263,10 @@ mod tests { }; let mut ec_context = EcContext::new_for_test(None, consent); let publisher_response = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -13158,10 +13377,12 @@ mod tests { /// as a substring rather than requiring inference. struct StubGeo(&'static str); + #[async_trait::async_trait(?Send)] impl crate::platform::PlatformGeo for StubGeo { - fn lookup( + async fn lookup( &self, _client_ip: Option, + _services: &crate::platform::RuntimeServices, ) -> Result, Report> { Ok(Some(GeoInfo { city: self.0.to_string(), @@ -13206,7 +13427,9 @@ mod tests { .build(); queue_shareable_html(&stub); - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let orchestrator = Arc::new(AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + )); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); let consent = crate::consent::ConsentContext { @@ -13215,7 +13438,10 @@ mod tests { }; let mut ec_context = EcContext::new_for_test(Some(user.ec_id.to_string()), consent); let publisher_response = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -13322,20 +13548,23 @@ mod tests { ); } - /// [`settings_with_mode`], with one integration configured a stated way. + /// [`settings_with_mode`], with one integration, core's stand-in, + /// configured a stated way. /// - /// Edits the parsed `[integrations]` map rather than appending TOML, so two + /// Edits the parsed settings rather than appending TOML, so two /// fixtures differ in exactly the field under test. - fn settings_with_prebid_timeout(mode: &str, timeout_ms: u32) -> Settings { + fn settings_with_integration_timeout(mode: &str, timeout_ms: u32) -> Settings { let mut settings = settings_with_mode(mode); - settings.integrations.insert( - "prebid".to_string(), - serde_json::json!({ - "enabled": true, - "external_bundle_url": "https://assets.example.com/prebid/bundle.js", - "timeout_ms": timeout_ms, - }), - ); + settings + .insert_module_config( + "testing", + crate::integrations::registry_test_support::deferred_fixture::MODULE, + &serde_json::json!({ + "label": "example", + "timeout_ms": timeout_ms, + }), + ) + .expect("should insert the stand-in's table"); settings } @@ -13373,8 +13602,8 @@ mod tests { let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let services = services(Arc::clone(&stub), Arc::clone(&cache)); - let first = Arc::new(settings_with_prebid_timeout("esi", 1000)); - let second = Arc::new(settings_with_prebid_timeout("esi", 2500)); + let first = Arc::new(settings_with_integration_timeout("esi", 1000)); + let second = Arc::new(settings_with_integration_timeout("esi", 2500)); queue_shareable_html(&stub); queue_shareable_html(&stub); @@ -13389,8 +13618,8 @@ mod tests { ); assert_ne!( stored[0], stored[1], - "two `[integrations]` configurations must key different templates; one key \ - serves the first configuration's injected markup to the second" + "two configurations of one integration must key different templates, because \ + one key serves the first configuration's injected markup to the second" ); assert_eq!( stub.recorded_request_uris().len(), @@ -13407,13 +13636,13 @@ mod tests { // moves between two equal configurations is a cache that never hits, which // this would read as "no measurable benefit" rather than as a bug. // - // The two `Settings` are parsed independently, so their `[integrations]` - // maps iterate in different orders — which is what exercises the sort. + // The two `Settings` are parsed independently, so the integration's + // table iterates in different orders, which is what exercises the sort. let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let services = services(Arc::clone(&stub), Arc::clone(&cache)); - let first = Arc::new(settings_with_prebid_timeout("esi", 1000)); - let second = Arc::new(settings_with_prebid_timeout("esi", 1000)); + let first = Arc::new(settings_with_integration_timeout("esi", 1000)); + let second = Arc::new(settings_with_integration_timeout("esi", 1000)); queue_shareable_html(&stub); let _ = run(&first, &services, navigation_request()).await; @@ -13634,13 +13863,11 @@ mod tests { request } - // Exercise raw fields at the prepared-request boundary. Ordinary diagnostics - // preparation removes invalid fields/empty pairs before generic cookie handling. + // Exercise raw fields at the prepared-request boundary. A module's preparer + // may remove invalid fields and empty pairs before generic cookie handling. fn prepared_cookie_policy_request(fields: &[&[u8]]) -> Request { let mut request = cookie_policy_request(fields); - request.extensions_mut().insert( - crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision::default(), - ); + IntegrationRegistry::mark_prepared_for_tests(&mut request); request } @@ -14036,8 +14263,12 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let mut ec_context = EcContext::new_for_test(None, crate::consent::ConsentContext::default()); + let registry = test_registry(&settings); let error = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -14151,12 +14382,18 @@ mod tests { let cache = Arc::new(MemoryTemplateCache::default()); let services = services(Arc::clone(&stub), Arc::clone(&cache)); queue_shareable_html(&stub); - // Normal preparation strips invalid fields and empty pairs before origin - // forwarding. Preserve it; the cache policy sees the prepared request. + // A module's preparer may rewrite the Cookie header before origin + // forwarding, as the stand-in does when it strips the cookie it + // reserves, dropping a field it cannot read and an empty pair as it + // goes. The cache policy sees the prepared request. + let reserved = format!( + "{}=1", + crate::integrations::registry_test_support::request_fixture::COOKIE_NAME + ); let cold = run( &settings, &services, - cookie_policy_request(&[b"ab_bucket=A;", b"unknown=\xff"]), + cookie_policy_request(&[b"ab_bucket=A;", b"unknown=\xff", reserved.as_bytes()]), ) .await; assert_eq!( @@ -14226,14 +14463,17 @@ mod tests { let first = cookie_policy_settings(None, None, true); let second = cookie_policy_settings(Some(&["ab_bucket"]), None, true); let third = cookie_policy_settings(Some(&["ab_bucket"]), Some(&["session"]), true); + // The registry is held constant, so the only thing the + // fingerprints differ on is the cookie policy. + let registry = test_registry(&first); assert_ne!( - template_fingerprint(&first), - template_fingerprint(&second), + template_fingerprint(&first, ®istry), + template_fingerprint(&second, ®istry), "should fingerprint the key policy" ); assert_ne!( - template_fingerprint(&second), - template_fingerprint(&third), + template_fingerprint(&second, ®istry), + template_fingerprint(&third, ®istry), "should fingerprint the bypass policy" ); let stub = Arc::new(StubHttpClient::new()); @@ -14362,10 +14602,8 @@ mod tests { async fn template_cookie_publisher_warm_variant_finalizes_ec_withdrawal() { for finalizer in [Finalizer::Streaming, Finalizer::Buffered] { let mut settings = cookie_policy_settings(Some(&["ab_bucket"]), None, true); - Arc::make_mut(&mut settings).auction.providers = - crate::auction_config_types::AuctionConfig::legacy_provider_map(&[ - SCHEDULING_PROVIDER, - ]); + Arc::make_mut(&mut settings).auction.provider_names = + vec![SCHEDULING_PROVIDER.to_owned()]; let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let services = services(Arc::clone(&stub), Arc::clone(&cache)); @@ -14443,7 +14681,10 @@ mod tests { let orchestrator = Arc::new(orchestrator); let cookies = format!("ab_bucket=A; ts-ec={identity}"); let response = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, Some(&graph), &mut ec_context, @@ -14478,7 +14719,9 @@ mod tests { None, None, &mut response, - ); + &services, + ) + .await; assert_eq!( response.headers()[HEADER_X_TS_TEMPLATE_CACHE], if index == 0 { "miss-stored" } else { "hit" }, @@ -14648,24 +14891,19 @@ mod tests { } #[tokio::test] - async fn an_active_diagnostics_request_never_stores_a_template() { - // An independent review reintroduced a diagnostics leak scoped to - // A request-private diagnostics mutation could otherwise leak through a - // shared template. `requires_private_no_store()` is a - // strict superset of the condition under which diagnostics markup is - // emitted, and that stamp lands *before* the template cache gate reads response headers, - // so such a request never stores a template at all. - // - // That is a coincidence between two independent conditions, and the whole - // protection rests on it. This pins the consequence directly, so the - // relationship is checked rather than merely reasoned about. + async fn a_request_a_module_left_state_on_never_stores_a_template() { + // What a module leaves on a request is written into that request's + // document, so a template stored from it would hand one reader's + // document to every later one. The request is kept off the shared + // path before the origin is asked, and this pins the consequence + // directly rather than leaving it to be reasoned about. let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let settings = Arc::new(settings_with_mode("esi")); let services = services(Arc::clone(&stub), Arc::clone(&cache)); queue_shareable_html(&stub); - let _ = run(&settings, &services, diagnostics_navigation_request()).await; + let _ = run(&settings, &services, marked_navigation_request()).await; assert!( cache @@ -14673,119 +14911,124 @@ mod tests { .lock() .expect("should lock entries") .is_empty(), - "a reader running diagnostics must not contribute to a shared cache" + "a request a module left state on must not contribute to a shared cache" ); } #[tokio::test] - async fn active_diagnostics_bypass_an_already_warm_template() { + async fn a_request_a_module_left_state_on_bypasses_an_already_warm_template() { let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); + let mut raw = settings_with_mode("esi"); + raw.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); + let settings = Arc::new(raw); let services = services(Arc::clone(&stub), Arc::clone(&cache)); queue_shareable_html(&stub); queue_shareable_html(&stub); let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let response = run(&settings, &services, diagnostics_navigation_request()).await; + let response = run(&settings, &services, marked_navigation_request()).await; + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store, private"), + "a document a module's request state is written into is one reader's" + ); + assert!( + response.headers().contains_key(header::SET_COOKIE), + "the module's response finalizer should run on the page path" + ); let document = String::from_utf8(body_of(response).await) - .expect("diagnostics document should be UTF-8"); + .expect("the marked request's document should be UTF-8"); assert_eq!( stub.recorded_request_uris().len(), 2, - "request-private diagnostics must reach the origin even when an ordinary \ - shared template is warm" + "a request a module left state on must reach the origin even when an \ + ordinary shared template is warm" ); assert_eq!( cache.lookups.lock().expect("should lock lookups").len(), 1, - "the diagnostics request must not consult template cache at all" + "the marked request must not consult template cache at all" ); assert!( - document.contains("__tsjs_gpt_diagnostics_active"), - "origin fallback must retain the request-private diagnostics bootstrap" + document.contains( + crate::integrations::registry_test_support::request_fixture::HEAD_FLAG + ), + "origin fallback must keep what the module writes for the request" ); } #[tokio::test] - async fn datadome_suppressed_request_bypasses_a_warm_shared_template() { + async fn a_personalized_request_bypasses_a_warm_shared_template() { let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); - let mut raw = settings_with_mode("esi"); - raw.integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "client_side_key": "test-client-key", - }), - ) - .expect("should configure DataDome integration"); - let settings = Arc::new(raw); + let settings = Arc::new(settings_with_mode("esi")); let services = services(Arc::clone(&stub), Arc::clone(&cache)); queue_shareable_html(&stub); queue_shareable_html(&stub); - let ordinary = run(&settings, &services, navigation_request()).await; - let ordinary_document = String::from_utf8(body_of(ordinary).await) - .expect("ordinary document should be UTF-8"); - assert!( - ordinary_document.contains("/integrations/datadome/tags.js"), - "the warm template fixture must contain the ordinary DataDome tag" - ); + let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let mut suppressed_request = navigation_request(); - suppressed_request + let mut personalized_request = navigation_request(); + personalized_request .headers_mut() .insert("sec-fetch-dest", HeaderValue::from_static("script")); - suppressed_request + personalized_request .extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); - let suppressed = run(&settings, &services, suppressed_request).await; + .insert(crate::response_privacy::PersonalizedResponse); + let personalized = run(&settings, &services, personalized_request).await; assert_eq!( - suppressed + personalized .headers() .get(HEADER_X_TS_TEMPLATE_CACHE) .and_then(|value| value.to_str().ok()), Some("bypass-request"), - "request-scoped tag suppression must not read a shared template" + "a personalized request must not read a shared template" ); - let suppressed_document = String::from_utf8(body_of(suppressed).await) - .expect("suppressed document should be UTF-8"); + let _ = body_of(personalized).await; assert_eq!( stub.recorded_request_uris().len(), 2, - "the suppressed navigation must reach the origin even when template cache is warm" + "the personalized request must reach the origin even when template cache is warm" ); assert_eq!( cache.lookups.lock().expect("should lock lookups").len(), 1, - "the suppressed request must bypass template cache before lookup" - ); - assert!( - !suppressed_document.contains("/integrations/datadome/tags.js"), - "the request-scoped suppression decision must survive origin processing" + "the personalized request must bypass template cache before lookup" ); } #[tokio::test] - async fn real_diagnostics_query_bypasses_template_cache_and_keeps_its_private_bootstrap() { + async fn a_query_a_module_acts_on_bypasses_template_cache_and_keeps_what_the_module_writes() + { let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let mut raw = settings_with_mode("esi"); - raw.integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should configure diagnostics"); + raw.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); let settings = Arc::new(raw); let services = services(Arc::clone(&stub), Arc::clone(&cache)); queue_shareable_html(&stub); + // The handler prepares a request that reaches it unprepared, so the + // module's preparer reads its query here as it does behind an adapter. let mut request = navigation_request(); - *request.uri_mut() = "https://ts.example.com/article?ts_console=1" - .parse() - .expect("should parse diagnostics URI"); + *request.uri_mut() = format!( + "https://ts.example.com/article?{}", + crate::integrations::registry_test_support::request_fixture::QUERY + ) + .parse() + .expect("should parse the URI"); let response = run(&settings, &services, request).await; assert_eq!( response @@ -14801,23 +15044,37 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("no-store, private") ); - assert!(response.headers().contains_key(header::SET_COOKIE)); - let document = String::from_utf8(body_of(response).await) - .expect("diagnostics document should be UTF-8"); - assert!(document.contains("__tsjs_gpt_diagnostics_active")); - assert!(document.contains("tsjs-gpt_diagnostics.min.js")); + assert!( + response.headers().contains_key(header::SET_COOKIE), + "the module's response finalizer should run on the page path" + ); + assert!( + stub.recorded_request_uris().iter().all(|uri| !uri + .contains(crate::integrations::registry_test_support::request_fixture::QUERY)), + "the origin should be asked without the query the module reserved" + ); + let document = + String::from_utf8(body_of(response).await).expect("the document should be UTF-8"); + assert!( + document.contains( + crate::integrations::registry_test_support::request_fixture::HEAD_FLAG + ) + ); + assert!(document.contains( + crate::integrations::registry_test_support::request_fixture::MODULE_FILE + )); assert!( cache .lookups .lock() .expect("should lock lookups") .is_empty(), - "the real query activation must bypass lookup before origin work" + "a query a module acts on must bypass lookup before origin work" ); } #[tokio::test] - async fn real_diagnostics_cookie_bypasses_a_warm_cookie_independent_template() { + async fn a_request_a_module_left_state_on_bypasses_a_warm_cookie_independent_template() { let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let mut raw = settings_with_mode("esi"); @@ -14825,21 +15082,17 @@ mod tests { .as_mut() .expect("fixture configures creative opportunities") .origin_is_cookie_independent = Some(true); - raw.integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should configure diagnostics"); + raw.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); let settings = Arc::new(raw); let services = services(Arc::clone(&stub), Arc::clone(&cache)); queue_shareable_html(&stub); queue_shareable_html(&stub); let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let mut diagnostics = navigation_request(); - diagnostics.headers_mut().insert( - header::COOKIE, - HeaderValue::from_static("__Host-ts-console=1"), - ); - let response = run(&settings, &services, diagnostics).await; + let response = run(&settings, &services, marked_navigation_request()).await; assert_eq!( response .headers() @@ -14847,16 +15100,20 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("bypass-request") ); - let document = String::from_utf8(body_of(response).await) - .expect("diagnostics document should be UTF-8"); + let document = + String::from_utf8(body_of(response).await).expect("the document should be UTF-8"); assert_eq!(stub.recorded_request_uris().len(), 2); assert_eq!( cache.lookups.lock().expect("should lock lookups").len(), 1, - "the diagnostics cookie must bypass the otherwise-eligible warm lookup" + "a request a module left state on must bypass the otherwise-eligible warm lookup" + ); + assert!( + document.contains( + crate::integrations::registry_test_support::request_fixture::HEAD_FLAG + ) ); - assert!(document.contains("__tsjs_gpt_diagnostics_active")); } #[tokio::test] @@ -15463,7 +15720,9 @@ mod tests { "the cold request should have populated the cache" ); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let consent = crate::consent::ConsentContext { jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, ..Default::default() @@ -15477,8 +15736,12 @@ mod tests { .header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz") .body(EdgeBody::empty()) .expect("should build authenticated request"); + let registry = test_registry(&settings); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -16297,10 +16560,9 @@ mod tests { } #[test] - fn a_datadome_block_is_refused_by_the_status_check() { - // DataDome replaces the document with a 403 - // (`integrations/datadome/protection.rs:778`). There is no separate - // marker to detect, and none is needed. + fn a_bot_protection_block_is_refused_by_the_status_check() { + // A bot protection module replaces the document with a 403. There + // is no separate marker to detect, and none is needed. assert_eq!( template_cache_bypass_reason( AssemblyMode::Esi, @@ -16521,7 +16783,7 @@ mod tests { #[tokio::test] async fn publisher_asset_cache_policy_applies_to_non_html_response() { - let settings = Settings::from_toml(&format!( + let settings = settings_from_toml(&format!( r#"{} [[cache.asset_rules]] @@ -16535,8 +16797,7 @@ mod tests { immutable = true "#, crate_test_settings_str() - )) - .expect("should parse settings with cache rule"); + )); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( 200, @@ -16808,7 +17069,7 @@ mod tests { #[tokio::test] async fn publisher_asset_policy_response_with_cookie_is_private_after_finalization() { - let settings = Settings::from_toml(&format!( + let settings = settings_from_toml(&format!( r#"{} [[cache.asset_rules]] @@ -16822,8 +17083,7 @@ mod tests { immutable = true "#, crate_test_settings_str() - )) - .expect("should parse settings with cache rule"); + )); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( 200, @@ -16869,7 +17129,7 @@ mod tests { #[tokio::test] async fn publisher_asset_cache_policy_skips_html_response() { - let settings = Settings::from_toml(&format!( + let settings = settings_from_toml(&format!( r#"{} [[cache.asset_rules]] @@ -16883,8 +17143,7 @@ mod tests { fingerprint_style = "hex" "#, crate_test_settings_str() - )) - .expect("should parse settings with cache rule"); + )); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( 200, @@ -16944,7 +17203,7 @@ mod tests { const ORIGIN_ETAG: &str = "\"origin-tag\""; const ORIGIN_LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT"; - const UNEXPECTED_304_PROVIDER: &str = "example-navigation-bidder"; + const UNEXPECTED_304_PROVIDER: &str = "example_navigation_bidder"; const UNEXPECTED_304_BACKEND: &str = "example-navigation-bidder-backend"; struct DispatchingTestProvider; @@ -17074,45 +17333,42 @@ mod tests { fn settings_with_enabled_auction_and_creative_opportunities() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\n\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 auction and creative opportunities enabled") + settings_from_toml(&toml) } fn settings_with_disabled_ad_templates() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\n\n\ + "{}\n\ [creative_opportunities]\nenabled = false\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 disabled ad templates") + settings_from_toml(&toml) } fn settings_with_disabled_auction() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = false\n\n\ + "{}\n\ [creative_opportunities]\ngam_network_id = \"12345\"\n", - crate_test_settings_str() + crate_test_settings_str().replace("[auction]\n", "[auction]\nenabled = false\n") ); - Settings::from_toml(&toml).expect("should parse settings with disabled auction") + settings_from_toml(&toml) } fn settings_without_creative_opportunities() -> Settings { - Settings::from_toml(&crate_test_settings_str()) - .expect("should parse settings without creative opportunities") + settings_from_toml(&crate_test_settings_str()) } fn settings_with_dispatching_provider() -> Settings { let toml = format!( - "{}\n[auction]\nenabled = true\n\n[auction.providers.{UNEXPECTED_304_PROVIDER}]\nprotocol = \"openrtb-2.6\"\nendpoint = \"https://unexpected.example/openrtb2/auction\"\nrouting = \"all_eligible\"\n\n\ + "{}\n[demand]\nmodules = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n[demand.{UNEXPECTED_304_PROVIDER}]\nimplementation = \"auction.plain-fixture\"\nendpoint = \"https://unexpected.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 the dispatching test provider") + settings_from_toml(&toml) } fn services_with_telemetry( @@ -17215,7 +17471,9 @@ mod tests { req: Request, consent: crate::consent::ConsentContext, ) -> PublisherResponse { - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(settings), + ); run_with_orchestrator_and_consent( settings, services, @@ -17255,8 +17513,12 @@ mod tests { ) -> PublisherResponse { let mut ec_context = EcContext::new_for_test(None, consent); + let registry = test_registry(settings); handle_publisher_request( - settings, + AppContext { + settings, + integration_registry: ®istry, + }, services, None, &mut ec_context, @@ -17299,7 +17561,9 @@ mod tests { #[tokio::test] async fn pending_origin_wait_failure_abandons_dispatched_auction_once() { let settings = settings_with_dispatching_provider(); - 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(DispatchingTestProvider)); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let stub = Arc::new(StubHttpClient::new()); @@ -17317,7 +17581,10 @@ mod tests { let slots = [article_slot()]; let error = match handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: &test_registry(&settings), + }, &services, Some(&graph), &mut ec_context, @@ -17367,7 +17634,9 @@ mod tests { #[tokio::test] async fn pending_origin_start_failure_skips_kv_auction_and_telemetry() { let settings = settings_with_dispatching_provider(); - 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(DispatchingTestProvider)); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let stub = Arc::new(StubHttpClient::new()); @@ -17388,7 +17657,10 @@ mod tests { let slots = [article_slot()]; let result = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: &test_registry(&settings), + }, &services, Some(&graph), &mut ec_context, @@ -17431,7 +17703,7 @@ mod tests { crate::auction::compile_auction_plan(&settings) .expect("should compile signed navigation auction"), ); - let orchestrator = crate::auction::build_orchestrator_with_plan(plan, &settings) + let orchestrator = crate::auction::build_orchestrator_with_plan(plan) .expect("should build signed plan-backed orchestrator"); let stub = Arc::new(StubHttpClient::new()); queue_html_response_with_cache_control(&stub, "public, max-age=300"); @@ -17660,7 +17932,9 @@ mod tests { .await; let registry = IntegrationRegistry::new(&settings).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 response = buffer_publisher_response_async( response, &Method::GET, @@ -17960,13 +18234,13 @@ mod tests { } #[tokio::test] - async fn inactive_ad_stack_preserves_gpt_diagnostics_cache_privacy() { + async fn inactive_ad_stack_preserves_the_cache_privacy_of_a_request_a_module_acts_on() { // Arrange let mut settings = settings_with_disabled_ad_templates(); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable GPT diagnostics"); + settings.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); let stub = Arc::new(StubHttpClient::new()); queue_html_response_with_cache_control(&stub, "no-cache"); let services = build_services_with_http_client( @@ -17974,11 +18248,14 @@ mod tests { ); let request = HttpRequest::builder() .method(Method::GET) - .uri("https://ts.example.com/article?ts_console=1") + .uri(format!( + "https://ts.example.com/article?{}", + crate::integrations::registry_test_support::request_fixture::QUERY + )) .header(header::HOST, "ts.example.com") .header("sec-fetch-dest", "document") .body(EdgeBody::empty()) - .expect("should build GPT diagnostics request"); + .expect("should build the request the module acts on"); // Act let response = run_with_slots(&settings, &services, &[article_slot()], request).await; @@ -17991,7 +18268,7 @@ mod tests { .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), Some("no-store, private"), - "active GPT diagnostics should retain cache privacy when server-side ad templates are inactive" + "a document a module wrote into should stay private when server-side ad templates are inactive" ); } @@ -18044,7 +18321,9 @@ mod tests { for content_type in [None, Some("text/html; charset=utf-8")] { // Arrange let settings = settings_with_dispatching_provider(); - 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(DispatchingTestProvider)); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let stub = Arc::new(StubHttpClient::new()); @@ -18305,7 +18584,7 @@ mod tests { } #[tokio::test] - async fn suppressed_navigation_removes_conditional_and_range_headers() { + async fn personalized_navigation_removes_conditional_and_range_headers() { let settings = create_test_settings(); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( @@ -18328,7 +18607,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build conditional request"); req.extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); + .insert(crate::response_privacy::PersonalizedResponse); let _response = run_publisher_proxy(&settings, &services, req).await; @@ -18347,13 +18626,13 @@ mod tests { headers .iter() .all(|(name, _)| !name.eq_ignore_ascii_case(header_name.as_str())), - "suppressed navigations must not forward {header_name}" + "personalized navigations must not forward {header_name}" ); } } #[tokio::test] - async fn suppressed_iframe_removes_conditional_and_range_headers() { + async fn personalized_iframe_removes_conditional_and_range_headers() { let settings = create_test_settings(); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( @@ -18376,7 +18655,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build conditional iframe request"); req.extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); + .insert(crate::response_privacy::PersonalizedResponse); let _response = run_publisher_proxy(&settings, &services, req).await; @@ -18395,13 +18674,13 @@ mod tests { headers .iter() .all(|(name, _)| !name.eq_ignore_ascii_case(header_name.as_str())), - "suppressed iframe documents must not forward {header_name}" + "personalized iframe documents must not forward {header_name}" ); } } #[tokio::test] - async fn suppressed_subresource_preserves_conditional_and_range_headers() { + async fn personalized_subresource_preserves_conditional_and_range_headers() { let settings = create_test_settings(); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( @@ -18424,7 +18703,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build conditional subresource request"); req.extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); + .insert(crate::response_privacy::PersonalizedResponse); let _response = run_publisher_proxy(&settings, &services, req).await; @@ -18445,7 +18724,7 @@ mod tests { .find(|(name, _)| name.eq_ignore_ascii_case(header_name.as_str())) .map(|(_, value)| value.as_str()), Some(expected), - "suppressed subresources should preserve {header_name}" + "personalized subresources should preserve {header_name}" ); } } @@ -18586,7 +18865,9 @@ mod tests { "test precondition: consent must allow EC creation" ); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let req = HttpRequest::builder() .method(Method::GET) .uri("https://publisher.example/article") @@ -18595,8 +18876,12 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); + let registry = test_registry(&settings); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -18619,39 +18904,32 @@ mod tests { } #[tokio::test] - async fn datadome_filter_marker_survives_into_publisher_html_pipeline() { + async fn what_a_request_filter_leaves_survives_into_the_publisher_html_pipeline() { let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": true, - "server_side_key_secret_name": "server-side-key", - "protection_excluded_ip_cidrs": ["192.0.2.0/24"], - "client_side_key": "test-client-key", - }), - ) - .expect("should configure DataDome integration"); + settings.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); let registry = IntegrationRegistry::new(&settings) - .expect("should create integration registry with DataDome"); + .expect("should create integration registry with the stand-in"); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( 200, b"content".to_vec(), vec![("content-type", "text/html; charset=utf-8")], ); - let services = build_services_with_secret_http_client_and_client_ip( - NoopSecretStore, - Arc::clone(&stub) as Arc, - Some("192.0.2.10".parse().expect("should parse client IP")), + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc ); let mut req = HttpRequest::builder() .method(Method::GET) .uri("https://publisher.example/page") .header(header::HOST, "publisher.example") .header("sec-fetch-dest", "document") + .header( + crate::integrations::registry_test_support::request_fixture::FILTER_HEADER, + "1", + ) .body(EdgeBody::empty()) .expect("should build request"); @@ -18664,7 +18942,7 @@ mod tests { permissions: None, }) .await - .expect("should run DataDome filter"); + .expect("should run the stand-in's filter"); assert!(matches!( filter_outcome, crate::integrations::RequestFilterRegistryOutcome::Continue(_) @@ -18675,15 +18953,19 @@ mod tests { &Method::GET, &settings, ®istry, - &AuctionOrchestrator::new(settings.auction.clone()), + &AuctionOrchestrator::new(crate::auction::test_support::legacy_auction_config( + &settings, + )), &services, ) .await .expect("should buffer publisher response"); let html = response_body_string(response); - assert!(!html.contains("window.ddjskey")); - assert!(!html.contains("/integrations/datadome/tags.js")); + assert!( + html.contains(crate::integrations::registry_test_support::request_fixture::HEAD_FLAG), + "what the filter left on the request should reach the document" + ); assert_eq!( stub.recorded_backend_names().len(), 1, @@ -18692,47 +18974,7 @@ mod tests { } #[test] - fn suppressed_datadome_tag_reaches_publisher_html_pipeline() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "client_side_key": "test-client-key", - }), - ) - .expect("should configure DataDome integration"); - let registry = IntegrationRegistry::new(&settings) - .expect("should create integration registry with DataDome"); - let mut params = make_stream_params(&settings, "identity"); - params.content_type = "text/html; charset=utf-8".to_string(); - params.suppress_datadome_client_side_tag = true; - let mut output = Vec::new(); - - stream_publisher_body( - EdgeBody::from(b"content".to_vec()), - &mut output, - ¶ms, - &settings, - ®istry, - ) - .expect("should process suppressed HTML"); - - let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); - assert!( - !html.contains("window.ddjskey"), - "publisher processing should omit the DataDome client configuration" - ); - assert!( - !html.contains("/integrations/datadome/tags.js"), - "publisher processing should omit the DataDome client tag URL" - ); - } - - #[test] - fn suppressed_datadome_html_is_private_and_not_shared_cached() { + fn personalized_html_is_private_and_not_shared_cached() { let mut response = Response::builder() .status(StatusCode::OK) .header(header::CACHE_CONTROL, "public, max-age=600") @@ -18745,7 +18987,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build cacheable HTML response"); - super::apply_datadome_client_tag_cache_privacy( + super::apply_personalized_response_cache_privacy( &mut response, &Method::GET, true, @@ -18758,31 +19000,31 @@ mod tests { .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), Some("no-store, private"), - "suppressed HTML should be private and non-storable" + "personalized HTML should be private and non-storable" ); assert!( response.headers().get("surrogate-control").is_none(), - "suppressed HTML should not retain Surrogate-Control" + "personalized HTML should not retain Surrogate-Control" ); assert!( response.headers().get("fastly-surrogate-control").is_none(), - "suppressed HTML should not retain Fastly-Surrogate-Control" + "personalized HTML should not retain Fastly-Surrogate-Control" ); assert!( response .headers() .get("cloudflare-cdn-cache-control") .is_none(), - "suppressed HTML should not retain Cloudflare-CDN-Cache-Control" + "personalized HTML should not retain Cloudflare-CDN-Cache-Control" ); assert!( response.headers().get("cdn-cache-control").is_none(), - "suppressed HTML should not retain CDN-Cache-Control" + "personalized HTML should not retain CDN-Cache-Control" ); for header_name in [header::ETAG, header::LAST_MODIFIED] { assert!( !response.headers().contains_key(&header_name), - "suppressed HTML should not retain {header_name}" + "personalized HTML should not retain {header_name}" ); } @@ -18791,7 +19033,7 @@ mod tests { .header(header::CACHE_CONTROL, "no-store") .body(EdgeBody::empty()) .expect("should build no-store HTML response"); - super::apply_datadome_client_tag_cache_privacy( + super::apply_personalized_response_cache_privacy( &mut no_store_response, &Method::GET, true, @@ -18803,12 +19045,12 @@ mod tests { .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), Some("no-store, private"), - "suppressed HTML should use the exact synthesized-HTML policy" + "personalized HTML should use the exact synthesized-HTML policy" ); } #[test] - fn datadome_cache_privacy_does_not_change_non_html_or_unsuppressed_responses() { + fn personalized_cache_privacy_does_not_change_non_html_or_ordinary_responses() { let mut response = Response::builder() .status(StatusCode::OK) .header(header::CACHE_CONTROL, "public, max-age=600") @@ -18816,7 +19058,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build cacheable response"); - super::apply_datadome_client_tag_cache_privacy( + super::apply_personalized_response_cache_privacy( &mut response, &Method::GET, false, @@ -18828,10 +19070,10 @@ mod tests { .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), Some("public, max-age=600"), - "unsuppressed HTML should retain its existing cache policy" + "ordinary HTML should retain its existing cache policy" ); - super::apply_datadome_client_tag_cache_privacy( + super::apply_personalized_response_cache_privacy( &mut response, &Method::GET, true, @@ -18847,6 +19089,86 @@ mod tests { ); } + #[test] + fn a_personalized_response_marker_from_any_integration_forces_private_caching() { + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CACHE_CONTROL, "public, max-age=600") + .header("surrogate-control", "max-age=600") + .header("fastly-surrogate-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") + .header("cdn-cache-control", "max-age=600") + .header(header::ETAG, "\"origin-tag\"") + .header(header::LAST_MODIFIED, "Wed, 21 Oct 2015 07:28:00 GMT") + .body(EdgeBody::empty()) + .expect("should build cacheable HTML response"); + + super::apply_personalized_response_cache_privacy( + &mut response, + &Method::GET, + true, + "text/html; charset=utf-8", + ); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store, private"), + "a personalized response should be private and non-storable" + ); + for header_name in [ + "surrogate-control", + "fastly-surrogate-control", + "cloudflare-cdn-cache-control", + "cdn-cache-control", + ] { + assert!( + response.headers().get(header_name).is_none(), + "a personalized response should not retain {header_name}" + ); + } + for header_name in [header::ETAG, header::LAST_MODIFIED] { + assert!( + !response.headers().contains_key(&header_name), + "a personalized response should not retain {header_name}" + ); + } + } + + #[test] + fn the_neutral_marker_alone_marks_a_response_personalized() { + let mut personalized = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build personalized request"); + personalized + .extensions_mut() + .insert(crate::response_privacy::PersonalizedResponse); + + assert!( + super::request_requires_personalized_delivery(&personalized), + "the neutral marker alone should mark a response personalized" + ); + + let ordinary = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build ordinary request"); + + assert!( + !super::request_requires_personalized_delivery(&ordinary), + "a request carrying no marker should not be personalized" + ); + } + #[test] fn response_carries_body_preserves_bodiless_metadata() { // A processable GET 200 buffers a body and recomputes Content-Length. @@ -18976,7 +19298,9 @@ mod tests { async fn parser_seam_loop_collects_before_writing_post_body_chunks() { let settings = create_test_settings(); let services = noop_services(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); let read_count = Arc::new(AtomicUsize::new(0)); let body_close_processed_at = Arc::new(AtomicUsize::new(0)); @@ -19236,7 +19560,9 @@ mod tests { // remains pending. let settings = create_test_settings(); let services = noop_services(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); let ad_bids_state = AdBidsState::default(); let token = b""; let mut state = AuctionHoldState::new( @@ -19859,27 +20185,24 @@ mod tests { } #[test] - fn tsjs_dynamic_serves_diagnostics_standalone_without_cookie_variance() { + fn tsjs_dynamic_serves_a_standalone_module_without_cookie_variance() { let mut settings = create_test_settings(); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let registry = IntegrationRegistry::with_plan( - &settings, - Arc::new( - crate::auction::compile_auction_plan(&settings) - .expect("should compile auction plan"), - ), - ) - .expect("should create integration registry"); + settings.select_module( + "testing", + crate::integrations::registry_test_support::request_fixture::MODULE, + ); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); let mut req = build_request( Method::GET, - "https://publisher.example/static/tsjs=tsjs-gpt_diagnostics.min.js", + &format!( + "https://publisher.example/static/tsjs={}", + crate::integrations::registry_test_support::request_fixture::MODULE_FILE + ), ); req.headers_mut().insert( header::COOKIE, - HeaderValue::from_static("__Host-ts-console=1"), + HeaderValue::from_static("ts-request-fixture=1"), ); let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SMaxageFallback) @@ -19899,45 +20222,77 @@ mod tests { #[test] fn parse_single_module_filename_extracts_known_id() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.min.js"), - Some("sourcepoint"), - "should extract sourcepoint from minified filename" + parse_single_module_filename("tsjs-creative.min.js", ®istry), + Some("creative"), + "should extract the module id from a minified filename" ); assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.js"), - Some("sourcepoint"), - "should extract sourcepoint from unminified filename" + parse_single_module_filename("tsjs-creative.js", ®istry), + Some("creative"), + "should extract the module id from an unminified filename" + ); + } + + #[test] + fn parse_single_module_filename_resolves_a_carried_module_id() { + let mut settings = create_test_settings(); + settings.select_module("testing", "testing.probe"); + let extra = [IntegrationBuilder::new( + "probe", + "seam-probe", + carried_probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build a registry with a carried module"); + + assert_eq!( + parse_single_module_filename("tsjs-probe.min.js", ®istry), + Some("probe"), + "should resolve a module id trusted-server-js has never heard of" ); } #[test] fn parse_single_module_filename_rejects_unknown_ids() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + assert_eq!( - parse_single_module_filename("tsjs-evil.min.js"), + parse_single_module_filename("tsjs-evil.min.js", ®istry), None, "should reject unknown module names" ); assert_eq!( - parse_single_module_filename("tsjs-core.min.js"), - Some("core"), - "should accept any known module ID (deferred check happens in caller)" + parse_single_module_filename("tsjs-core.min.js", ®istry), + None, + "should not resolve core, which is only ever served inside the bundle" ); assert_eq!( - parse_single_module_filename("prebid.min.js"), + parse_single_module_filename("creative.min.js", ®istry), None, "should reject without tsjs- prefix" ); assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.txt"), + parse_single_module_filename("tsjs-creative.txt", ®istry), None, "should reject non-js extension" ); } #[test] - fn tsjs_dynamic_serves_prebid_shim_when_enabled() { - let settings = create_test_settings(); + fn tsjs_dynamic_serves_a_deferred_module_when_it_is_selected() { + use crate::integrations::registry_test_support::deferred_fixture; + + let mut settings = create_test_settings(); + settings.select_module("testing", deferred_fixture::MODULE); let registry = IntegrationRegistry::with_plan( &settings, Arc::new( @@ -19948,7 +20303,10 @@ mod tests { .expect("should create integration registry"); let req = build_request( Method::GET, - "https://publisher.example/static/tsjs=tsjs-prebid.min.js", + &format!( + "https://publisher.example/static/tsjs={}", + deferred_fixture::MODULE_FILE + ), ); let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SMaxageFallback) @@ -19956,23 +20314,13 @@ mod tests { assert_eq!( response.status(), StatusCode::OK, - "should serve the deferred prebid shim module when prebid is enabled" + "should serve the deferred module when its integration is selected" ); } #[test] - fn tsjs_dynamic_returns_not_found_for_disabled_deferred_module() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "prebid", - &serde_json::json!({ - "enabled": false, - "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", - }), - ) - .expect("should update prebid config"); + fn tsjs_dynamic_returns_not_found_for_a_module_that_is_not_named() { + let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( &settings, Arc::new( @@ -19983,7 +20331,10 @@ mod tests { .expect("should create integration registry"); let req = build_request( Method::GET, - "https://publisher.example/static/tsjs=tsjs-prebid.min.js", + &format!( + "https://publisher.example/static/tsjs={}", + crate::integrations::registry_test_support::deferred_fixture::MODULE_FILE + ), ); let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SMaxageFallback) @@ -19991,7 +20342,7 @@ mod tests { assert_eq!( response.status(), StatusCode::NOT_FOUND, - "should return 404 for disabled deferred module" + "should return 404 for a deferred module that is not named" ); } @@ -20087,6 +20438,211 @@ mod tests { ); } + fn body_text(response: http::Response) -> String { + let bytes = response + .into_body() + .into_bytes() + .expect("should read the tsjs response body") + .to_vec(); + String::from_utf8(bytes).expect("should serve UTF-8 JavaScript") + } + + fn cache_control_text(response: &http::Response) -> String { + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string() + } + + fn carried_deferred_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .with_js_module(CarriedJsModule { + source: PROBE_JS, + sha256: PROBE_JS_SHA256, + }) + .with_deferred_js() + .build(), + )) + } + + #[test] + fn tsjs_dynamic_serves_a_carried_module_in_the_unified_bundle_under_the_composed_hash() { + let mut settings = create_test_settings(); + settings.select_module("testing", "testing.probe"); + let extra = [IntegrationBuilder::new( + "probe", + "seam-probe", + carried_probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build a registry with a carried module"); + let parts = registry.js_parts_immediate(); + let expected_hash = crate::tsjs_bundle::compose_hash(&parts); + let request = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-unified.min.js?v={expected_hash}"), + ); + + let response = handle_tsjs_dynamic(&request, ®istry, EdgeCacheHeader::SMaxageFallback) + .expect("should handle tsjs request"); + + assert_eq!(response.status(), StatusCode::OK, "should serve the bundle"); + assert!( + cache_control_text(&response).contains("immutable"), + "should treat the composed hash as the matching version" + ); + let body = body_text(response); + let core = trusted_server_js::module_bundle("core").expect("should have compiled core in"); + assert!( + body.starts_with(core), + "should put the compile-time core first in the bundle" + ); + assert!( + body.contains("window.__probe=1"), + "should serve the carried module inside the unified bundle" + ); + } + + #[test] + fn tsjs_dynamic_serves_a_carried_deferred_module_standalone() { + let mut settings = create_test_settings(); + settings.select_module("testing", "testing.probe"); + let extra = [IntegrationBuilder::new( + "probe", + "seam-probe", + carried_deferred_probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build a registry with a carried deferred module"); + let request = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-probe.min.js?v={PROBE_JS_SHA256}"), + ); + + let response = handle_tsjs_dynamic(&request, ®istry, EdgeCacheHeader::SMaxageFallback) + .expect("should handle tsjs request"); + + assert_eq!( + response.status(), + StatusCode::OK, + "should serve a carried deferred module as its own file" + ); + assert!( + cache_control_text(&response).contains("immutable"), + "should treat the carried module's own hash as the matching version" + ); + assert_eq!( + body_text(response), + PROBE_JS, + "should serve the carried source verbatim" + ); + } + + fn carried_standalone_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .with_js_module(CarriedJsModule { + source: PROBE_JS, + sha256: PROBE_JS_SHA256, + }) + .with_standalone_js() + .build(), + )) + } + + #[test] + fn tsjs_dynamic_serves_a_standalone_module_by_registration_flag_not_by_name() { + // The module the registration marks standalone is one a vendor crate + // carries, so nothing in the binary knows its name. A branch that named + // an integration instead of reading the flag could not serve it, and a + // reintroduced constant would fail this test rather than pass it. + let mut settings = create_test_settings(); + settings.select_module("testing", "testing.probe"); + let extra = [IntegrationBuilder::new( + "probe", + "standalone-probe", + carried_standalone_probe_registration, + validate_nothing, + ) + .with_module_name("testing.probe")]; + let registry = IntegrationRegistry::with_registrations(&settings, &extra) + .expect("should build a registry with a carried standalone module"); + assert!( + registry.js_module_ids_immediate().contains(&"creative"), + "fixture should put core's creative module in the unified bundle" + ); + assert!( + !registry.js_module_ids().contains(&"probe"), + "a standalone module should stay out of the bundled module ids" + ); + + let standalone = handle_tsjs_dynamic( + &build_request( + Method::GET, + &format!( + "https://publisher.example/static/tsjs=tsjs-probe.min.js?v={PROBE_JS_SHA256}" + ), + ), + ®istry, + EdgeCacheHeader::SMaxageFallback, + ) + .expect("should handle tsjs request"); + assert_eq!( + standalone.status(), + StatusCode::OK, + "should serve a module its registration marks standalone" + ); + assert!( + cache_control_text(&standalone).contains("immutable"), + "should treat the carried module's own hash as the matching version" + ); + assert_eq!( + body_text(standalone), + PROBE_JS, + "should serve the standalone module as its own file" + ); + + let unified = handle_tsjs_dynamic( + &build_request( + Method::GET, + "https://publisher.example/static/tsjs=tsjs-unified.min.js", + ), + ®istry, + EdgeCacheHeader::SMaxageFallback, + ) + .expect("should handle tsjs request"); + assert!( + !body_text(unified).contains(PROBE_JS), + "a standalone module should stay out of the unified bundle" + ); + + let bundled_only = handle_tsjs_dynamic( + &build_request( + Method::GET, + "https://publisher.example/static/tsjs=tsjs-lockr.min.js", + ), + ®istry, + EdgeCacheHeader::SMaxageFallback, + ) + .expect("should handle tsjs request"); + assert_eq!( + bundled_only.status(), + StatusCode::NOT_FOUND, + "should not serve a bundle-only module as a single file" + ); + } + #[test] fn tsjs_dynamic_keeps_short_cache_for_mismatched_hash() { let settings = create_test_settings(); @@ -20198,8 +20754,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(); @@ -20258,8 +20813,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(); @@ -20307,8 +20861,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 body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), @@ -20324,8 +20877,8 @@ mod tests { ); } - #[test] - fn body_chunk_source_yields_once_body_in_chunks() { + #[tokio::test] + async fn body_chunk_source_yields_once_body_in_chunks() { futures::executor::block_on(async { let body = EdgeBody::from_bytes(bytes::Bytes::from_static(b"abcdef")); let mut source = BodyChunkSource::new(body, 3).with_max_bytes(16); @@ -20347,8 +20900,8 @@ mod tests { }); } - #[test] - fn body_chunk_source_preserves_stream_chunks() { + #[tokio::test] + async fn body_chunk_source_preserves_stream_chunks() { futures::executor::block_on(async { let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"first"), @@ -20373,8 +20926,8 @@ mod tests { }); } - #[test] - fn body_chunk_source_enforces_cumulative_raw_cap() { + #[tokio::test] + async fn body_chunk_source_enforces_cumulative_raw_cap() { futures::executor::block_on(async { let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"1234"), @@ -20402,8 +20955,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_processes_stream_without_auction() { + #[tokio::test] + async fn stream_publisher_body_async_processes_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20414,7 +20967,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, @@ -20434,8 +20989,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 body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), @@ -20467,8 +21021,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_processes_gzip_stream_without_auction() { + #[tokio::test] + async fn stream_publisher_body_async_processes_gzip_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20479,7 +21033,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, @@ -20499,8 +21055,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 compressed = gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -20535,8 +21090,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_processes_deflate_stream_without_auction() { + #[tokio::test] + async fn stream_publisher_body_async_processes_deflate_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20547,7 +21102,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, @@ -20567,8 +21124,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 compressed = deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -20603,8 +21159,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_processes_brotli_stream_without_auction() { + #[tokio::test] + async fn stream_publisher_body_async_processes_brotli_stream_without_auction() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20615,7 +21171,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, @@ -20635,8 +21193,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 compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -20671,8 +21228,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_rejects_truncated_brotli_stream() { + #[tokio::test] + async fn stream_publisher_body_async_rejects_truncated_brotli_stream() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20683,7 +21240,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, @@ -20703,8 +21262,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 compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -20753,13 +21311,12 @@ 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(), } } - #[test] - fn stream_publisher_body_async_rejects_truncated_gzip_stream() { + #[tokio::test] + async fn stream_publisher_body_async_rejects_truncated_gzip_stream() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20770,7 +21327,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 = non_html_stream_params("gzip"); let compressed = @@ -20801,8 +21360,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_rejects_truncated_deflate_stream() { + #[tokio::test] + async fn stream_publisher_body_async_rejects_truncated_deflate_stream() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20813,7 +21372,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 = non_html_stream_params("deflate"); let compressed = @@ -20845,8 +21406,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_enforces_decoded_byte_cap() { + #[tokio::test] + async fn stream_publisher_body_async_enforces_decoded_byte_cap() { futures::executor::block_on(async { let mut settings = create_test_settings(); // Raw compressed input stays tiny (well under the cap); only the @@ -20861,7 +21422,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 = non_html_stream_params("gzip"); let compressed = gzip_encode(&vec![b'a'; 64 * 1024]); @@ -20892,8 +21455,8 @@ mod tests { }); } - #[test] - fn body_chunk_source_resumes_after_cancelled_poll() { + #[tokio::test] + async fn body_chunk_source_resumes_after_cancelled_poll() { futures::executor::block_on(async { let mut pending_once = true; let mut yielded = false; @@ -20938,8 +21501,8 @@ mod tests { }); } - #[test] - fn stream_publisher_body_async_processes_stream_with_auction_hold() { + #[tokio::test] + async fn stream_publisher_body_async_processes_stream_with_auction_hold() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = IntegrationRegistry::with_plan( @@ -20950,7 +21513,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 { @@ -20977,8 +21542,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"), @@ -21014,8 +21578,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( @@ -21026,7 +21590,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 { @@ -21053,8 +21619,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 @@ -21093,8 +21658,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( @@ -21105,7 +21670,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, @@ -21128,8 +21695,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')}", @@ -21172,7 +21738,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) @@ -21197,8 +21765,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 publisher_response = PublisherResponse::Stream { response, @@ -21305,7 +21872,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) @@ -21354,24 +21923,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), @@ -21384,12 +21953,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}" ); } @@ -21437,18 +22006,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( @@ -21466,11 +22029,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"), @@ -21571,7 +22134,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( @@ -21614,24 +22179,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), @@ -21658,15 +22215,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 { @@ -21729,7 +22281,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("), @@ -21777,7 +22329,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!( @@ -21859,7 +22412,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 { @@ -21924,7 +22479,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 @@ -22010,7 +22567,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 = @@ -22043,8 +22602,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 { @@ -22192,7 +22750,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) @@ -22235,8 +22795,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, @@ -22314,8 +22873,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(); @@ -22376,8 +22934,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 bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -22435,18 +22992,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, @@ -22459,7 +23010,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( @@ -22495,8 +23046,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(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -22518,22 +23068,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( @@ -22543,8 +23087,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, @@ -22563,8 +23107,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(); @@ -22575,11 +23118,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!( @@ -22595,11 +23140,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, @@ -22865,28 +23410,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"); } @@ -23769,38 +24314,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()); @@ -23809,6 +24356,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 @@ -24301,8 +24909,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") } @@ -24310,23 +24918,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") } @@ -24397,7 +25008,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, @@ -24429,7 +25042,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, @@ -24508,23 +25123,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()); @@ -24579,7 +25205,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, @@ -24629,10 +25255,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()); @@ -24676,32 +25302,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" ); } @@ -24716,7 +25344,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, @@ -24753,7 +25383,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, @@ -24797,7 +25429,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, @@ -24824,7 +25458,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=/")) @@ -24844,7 +25480,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"); @@ -24861,7 +25499,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"); @@ -24878,7 +25518,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"); @@ -24896,7 +25538,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. @@ -24917,7 +25561,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; @@ -24946,7 +25592,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( @@ -24980,7 +25628,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"); @@ -25008,7 +25658,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() @@ -25049,7 +25701,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 @@ -25111,7 +25765,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/"); @@ -25140,7 +25796,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/"); @@ -25171,7 +25829,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/"); @@ -25268,7 +25928,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. @@ -25337,11 +25997,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 { @@ -25425,7 +26085,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), })); @@ -25510,8 +26172,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, @@ -25594,8 +26260,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, @@ -25765,7 +26435,10 @@ mod tests { .expect("should build test request"); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: &test_registry(&settings), + }, &services, Some(&graph), &mut ec_context, @@ -25821,7 +26494,12 @@ mod tests { 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)) + .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(), @@ -25837,7 +26515,10 @@ mod tests { .expect("should build test request"); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: &test_registry(&settings), + }, &services, Some(&graph), &mut ec_context, @@ -25861,7 +26542,9 @@ mod tests { None, None, &mut response, - ); + &crate::platform::test_support::noop_services(), + ) + .await; let cookies: Vec<&str> = response .headers() diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index caa1a0fd0e..3859849129 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); } 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 721bfaca05..116ebdc5c2 100644 --- a/crates/trusted-server-core/src/secret_resolution.rs +++ b/crates/trusted-server-core/src/secret_resolution.rs @@ -47,9 +47,32 @@ pub fn resolve_secret_references( secret_store: &dyn PlatformSecretStore, default_store_name: &StoreName, ) -> Result<(), Report> { + resolve_secret_references_with::(data, secret_store, default_store_name, Vec::new()) +} + +/// 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. +/// +/// # 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<(), Report> { + let mut listed = std::collections::BTreeSet::new(); let fields = C::secret_fields() .into_iter() .chain(C::configured_secret_fields(data)) + .chain(extra) + .filter(|field| listed.insert(field.dotted_path())) .collect::>(); let mut resolved_data = data.clone(); for field in fields { @@ -345,6 +368,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!({ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 092b46c6aa..daf15e8af0 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -27,6 +27,7 @@ use crate::ec::module::{ 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)] @@ -225,165 +226,162 @@ impl Publisher { } } -#[derive(Default, Clone, Deserialize, Serialize)] -pub struct IntegrationSettings { - #[serde(flatten)] - entries: HashMap, -} +/// 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 IntegrationSettings { +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 }))?; } + Ok(()) + } +} - config.validate().map_err(|err| { +/// Reads and validates a table as `T`, where `place` is the table's name as +/// written, such as `shared.example`. +/// +/// A message names the setting at fault and the rule it broke, and never +/// quotes the setting's value, because at startup a table holds each of its +/// secrets resolved from the secret store. +fn read_table( + place: &str, + table: serde_json::Map, +) -> Result> +where + T: DeserializeOwned + Validate, +{ + let config: T = + serde_path_to_error::deserialize(JsonValue::Object(table)).map_err(|error| { Report::new(TrustedServerError::Configuration { message: format!( - "Integration '{integration_id}' configuration failed validation: {err}" + "[{place}] could not be read: {}", + read_error_summary(&error) ), }) })?; - - Ok(Some(config)) - } + config.validate().map_err(|errors| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[{place}] failed validation: {}", + validation_error_summary(&errors) + ), + }) + })?; + Ok(config) } -impl Deref for IntegrationSettings { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.entries +/// Why a table could not be read, by the path of the setting at fault. +/// +/// `serde` quotes the value it could not read, so its message is kept only +/// when it names keys alone: a key missing, unknown or written twice. +fn read_error_summary(error: &serde_path_to_error::Error) -> String { + let message = error.inner().to_string(); + let names_keys_only = ["missing field `", "unknown field `", "duplicate field `"] + .iter() + .any(|start| message.starts_with(start)); + let path = error.path().to_string(); + match (path.as_str(), names_keys_only) { + (".", true) => message, + (".", false) => "a setting holds a value of the wrong type or form".to_owned(), + (path, true) => format!("{path}: {message}"), + (path, false) => format!("`{path}` holds a value of the wrong type or form"), } } -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]]`. /// @@ -1308,20 +1306,21 @@ impl DeviceConfig { self.module.as_deref().unwrap_or("builtin") } - /// Validates that the selected device-detection module is available in - /// this build. + /// 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`] when the selected module - /// key is not one this build provides. + /// 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> { - match self.module_key() { - "builtin" | "fastly" => Ok(()), - key => Err(Report::new(TrustedServerError::Configuration { - message: format!("Device detection module `{key}` is not available in this build"), - })), - } + Ok(()) } } @@ -1482,12 +1481,11 @@ impl GeoConfig { /// Returns [`TrustedServerError::Configuration`] when the selected module /// key is not one this build provides. pub fn validate_module_selection(&self) -> Result<(), Report> { - match self.module.as_deref() { - None | Some("platform") | Some("none") => Ok(()), - Some(key) => Err(Report::new(TrustedServerError::Configuration { - message: format!("Geo module `{key}` is not available in this build"), - })), - } + // 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 running jurisdiction consumers without geolocation is @@ -1511,7 +1509,11 @@ impl GeoConfig { &self, ec: &Ec, ) -> Result<(), Report> { - let geo_disabled = !matches!(self.module.as_deref(), Some("platform")); + // 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. @@ -2656,7 +2658,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. @@ -2672,13 +2673,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 { @@ -2699,6 +2704,7 @@ impl Default for Proxy { certificate_check: default_certificate_check(), allowed_domains: Vec::new(), asset_routes: Vec::new(), + modules: SectionModules::default(), } } } @@ -3584,6 +3590,34 @@ 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 { @@ -3608,6 +3642,21 @@ macro_rules! refused_table { }; } +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 \ @@ -3629,9 +3678,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")] @@ -3673,7 +3722,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, @@ -3898,7 +3947,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, @@ -3919,8 +3967,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, @@ -3933,6 +4003,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)] @@ -4115,8 +4205,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(()) } @@ -4140,6 +4228,7 @@ 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()?; settings @@ -4505,19 +4594,173 @@ 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 name the setting at fault in the outermost message, because a + /// report renders only that 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 and the setting at fault but never the setting's value. + 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(); + read_table(&format!("{section}.{written}"), table).map(Some) + } + + /// 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(()) } } @@ -4605,8 +4848,13 @@ fn validation_error_summary(errors: &validator::ValidationErrors) -> String { }; match kind { validator::ValidationErrorsKind::Field(validations) => { + // A validator's message, where it gives one, says why the + // value was refused, which the code alone does not. for validation in validations { - messages.push(format!("{path}: {}", validation.code)); + messages.push(match &validation.message { + Some(message) => format!("{path}: {} ({message})", validation.code), + None => format!("{path}: {}", validation.code), + }); } } validator::ValidationErrorsKind::Struct(inner) => { @@ -4724,7 +4972,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: @@ -4786,7 +5034,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, @@ -4862,7 +5121,6 @@ mod tests { use regex::Regex; use serde_json::json; use std::collections::BTreeSet; - use std::sync::Arc; use crate::ec::resolve::NotAnOrigin; @@ -4917,11 +5175,7 @@ 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, crate_test_settings_str_with_ec_section, create_test_settings, @@ -5018,6 +5272,8 @@ mod tests { // 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"); @@ -5025,6 +5281,59 @@ mod tests { .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( @@ -5080,7 +5389,7 @@ 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(); @@ -5139,20 +5448,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:?}"); @@ -5196,10 +5503,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 { @@ -5616,7 +5920,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"); @@ -5626,16 +5930,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) @@ -5646,8 +5951,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}" ); } @@ -5655,7 +5960,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, @@ -5913,27 +6218,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"); @@ -6787,12 +7081,30 @@ mod tests { .validate_module_selection() .expect("should validate the fastly opt-in"); - let unknown = DeviceConfig { + // 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!( - unknown.validate_module_selection().is_err(), - "an unknown device module should be rejected at startup" + message.contains("acme") && message.contains("[device] module"), + "the error should name the selector and the module, got: {message}" ); } @@ -6927,13 +7239,35 @@ passphrase = "another-test-secret-key-32-bytes" none.validate_module_selection() .expect("should validate the explicit opt-out of geolocation"); - let unknown = GeoConfig { + // 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!( - unknown.validate_module_selection().is_err(), - "an unknown geo module should be rejected at startup" + message.contains("acme") && message.contains("[geo] module"), + "the error should name the selector and the module, got: {message}" ); } @@ -8276,145 +8610,296 @@ source_domain = "partner.example.com" ); } - #[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 {} + + /// 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!(config.is_none(), "Disabled integrations should be skipped"); + 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"), - ), - ) - .expect("disabled default-enabled prebid config should not fail registry startup"); - build_orchestrator(&settings) - .expect("minimal disabled prebid config should not fail orchestrator startup"); + let error = settings + .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}" + ); + } + + /// An example module's settings with a secret in them, as its table holds + /// them at startup, once the secret is resolved from the store. + #[derive(Debug, Deserialize, Validate)] + #[serde(deny_unknown_fields)] + struct SecretSetting { + #[validate(url)] + endpoint: String, + #[validate(range(max = 10))] + retries: Option, } + impl IntegrationConfig for SecretSetting {} + #[test] - fn disabled_removed_prebid_and_aps_fields_are_rejected() { - for (integration_id, removed_field) in [("prebid", "server_url"), ("aps", "account_id")] { + fn a_module_table_that_is_refused_names_the_setting_and_never_its_value() { + const SECRET: &str = "resolved-secret-value-4f7a"; + for (table, setting) in [ + // Fails a rule, whose parameters carry the value checked. + (serde_json::json!({ "endpoint": SECRET }), "endpoint"), + // Cannot be read, where `serde` quotes the value it found. + ( + serde_json::json!({ "endpoint": "https://example.com", "retries": SECRET }), + "retries", + ), + ] { 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"), - }; + .insert_module_config(EXAMPLE_SECTION, ENDPOINT_MODULE, &table) + .expect("should insert the module's table"); + + let error = settings + .module_config::(ENDPOINT_MODULE) + .expect_err("the table breaks the module's rules"); + + let shown = format!("{error}\n{error:?}"); + assert!( + !shown.contains(SECRET), + "printed a setting's value: {shown}" + ); + // The module's name holds `endpoint` too, so the setting is looked + // for after the table's name. + let rendered = error.to_string(); + let reason = rendered + .split_once("[example.endpoint]") + .map_or("", |(_, reason)| reason); + assert!( + reason.contains(setting), + "should name the table and the 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", + ), + ] { + 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 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:?}" ); } @@ -8470,11 +8955,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"); @@ -8490,12 +8972,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"); @@ -8516,12 +8996,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, @@ -8531,12 +9006,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(), @@ -8555,6 +9028,7 @@ source_domain = "partner.example.com" "*.Example.Org".to_string(), ], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert_eq!( @@ -8575,6 +9049,7 @@ source_domain = "partner.example.com" "cdn.example.com".to_string(), ], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert_eq!( @@ -8590,6 +9065,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!( @@ -8605,6 +9081,7 @@ source_domain = "partner.example.com" certificate_check: true, allowed_domains: vec!["*".to_string()], asset_routes: vec![], + modules: SectionModules::default(), }; proxy.normalize(); assert!( @@ -8619,6 +9096,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!( @@ -8637,6 +9115,7 @@ source_domain = "partner.example.com" origin_url: " https://assets.example.com ".to_string(), ..Default::default() }], + modules: SectionModules::default(), }; proxy.normalize(); assert_eq!( @@ -8661,6 +9140,7 @@ source_domain = "partner.example.com" target_path: Some(" /rewritten/$1 ".to_string()), ..Default::default() }], + modules: SectionModules::default(), }; proxy.normalize(); @@ -9385,6 +9865,7 @@ source_domain = "partner.example.com" ..Default::default() }, ], + modules: SectionModules::default(), }; let route = proxy @@ -9413,6 +9894,7 @@ source_domain = "partner.example.com" ..Default::default() }, ], + modules: SectionModules::default(), }; let route = proxy @@ -10501,6 +10983,20 @@ mod permission_signal_config_tests { ); } + #[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 diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 2b7d92b3c6..8d6438268d 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, PolicyOrigin, StoreName}; use crate::settings::Settings; @@ -68,8 +69,14 @@ 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, with the -/// permission policy they name read from `policy_origin`. +/// Loads [`Settings`] from a platform config store and key, validating +/// against the built-in integrations only, with the permission policy they +/// name read from `policy_origin`. +/// +/// 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 /// @@ -84,14 +91,45 @@ pub fn get_settings_from_config_store( key: &str, default_secret_store_name: &StoreName, policy_origin: PolicyOrigin<'_>, +) -> Result> { + get_settings_from_config_store_with( + config_store, + secret_store, + store_name, + key, + default_secret_store_name, + policy_origin, + &[], + ) +} + +/// Loads [`Settings`] from a platform config store and key, validating +/// against the built-in integrations followed by `extra_integrations`, with +/// the permission policy they name read from `policy_origin`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the config blob is +/// missing, cannot be read, fails envelope verification, secret resolution, +/// or Trusted Server settings validation, or when its permission policy does +/// not load. +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, + policy_origin: PolicyOrigin<'_>, + 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( + settings_from_config_blob_with( &envelope_json, secret_store, default_secret_store_name, policy_origin, + extra_integrations, ) } diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index d5d0a20139..59c3f2187e 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -1,4 +1,4 @@ -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] pub mod tests { use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource, TcfConsent}; @@ -33,16 +33,7 @@ pub mod tests { # is acknowledged the same way a deployment would. assume_single_jurisdiction = true - [integrations.prebid] - enabled = true - external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" - - [integrations.prebid.bundle.modules] - bidder = ["exampleBidderBidAdapter"] - - [integrations.nextjs] - enabled = false - rewrite_attributes = ["href", "link", "url"] + [auction] [ec] module = "hmac" @@ -57,6 +48,24 @@ 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. @@ -151,6 +160,10 @@ pub mod tests { /// 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) @@ -210,6 +223,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 @@ -262,34 +344,46 @@ pub mod nextjs_auction { [ec] passphrase = "test-secret-key-32-bytes-minimum" - # The fixture serves one publisher in one place, so it says so - # rather than selecting a geo module it has no use for. + # 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] - assume_single_jurisdiction = true + 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#" @@ -435,10 +529,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 b0fca38a3e..0251a4bb85 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -27,6 +27,7 @@ serde_json = { workspace = true } sha2 = { 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 5d6e0aabd3..c3c81bb69e 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 @@ -44,67 +44,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 @@ -114,47 +58,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/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index c13654ed7b..d5c2e29bde 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 @@ -173,6 +173,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:?}")))?; @@ -282,12 +285,12 @@ 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::permissions::{SAMPLE_POLICY_NAME, SAMPLE_POLICY_TEXT}; use trusted_server_core::platform::{ PlatformConfigStore, PlatformError, PlatformSecretStore, PolicyOrigin, StoreId, StoreName, }; - use trusted_server_core::settings_data::get_settings_from_config_store; + use trusted_server_core::settings_data::get_settings_from_config_store_with; /// The repository's sample, which the fixture names. fn sample() -> PolicyFile { @@ -501,13 +504,14 @@ mod tests { .expect("should inject generated stores"); let store = GeneratedConfigStore(generated_entries(&generated)); - let settings = get_settings_from_config_store( + let settings = get_settings_from_config_store_with( &store, &integration_secret_store(), &StoreName::from(DEFAULT_CONFIG_STORE_ID), CONFIG_BLOB_KEY, &StoreName::from("trusted_server_secrets"), PolicyOrigin::Blob, + &trusted_server_modules::builders(), ) .expect("should load the settings through the chunk pointer"); @@ -532,11 +536,12 @@ mod tests { let envelope = build_app_config_envelope(APP_CONFIG, &sample(), 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"), PolicyOrigin::Blob, + &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 a5284f8d5e..b21aec8cc3 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()); // What `ts config push --permissions` writes for the sample the fixture // names. Axum reads the file from its folder and leaves the text unused. settings.permissions.sha256 = Some(policy_sha256(SAMPLE_POLICY_TEXT)); @@ -57,14 +60,18 @@ fn app_config_envelope( /// Builds the integration app-config envelope 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 auction_app_config_envelope(origin_port: u16) -> TestResult { 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(()) }) } 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-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/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/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/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/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 c5a01ad6a6..30c42985fe 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -174,7 +174,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 +183,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 +290,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:** @@ -426,11 +426,11 @@ 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` demo). Server-side modules such as HMAC do not use it. +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 the `client_fixed` demo this is the fixed known word sent as `text/plain`. +**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`. @@ -1060,7 +1060,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. --- @@ -1068,65 +1068,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 @@ -1134,13 +1136,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 | @@ -1153,7 +1155,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 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 de5378be5c..34b2f4cc4a 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`. + +Four settings are common to every `[demand.]` table, whichever +implementation it names: -Common provider fields and defaults: +| 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 | -| 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 | +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 8daf8837e0..b433b24b95 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/axum-dev.md b/docs/guide/axum-dev.md index 5ca965d6ef..503009d7e3 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -62,10 +62,10 @@ Unrecognized values, including per-module filters such as error at startup. The `trace` level can log auction payloads and TCF consent strings, for example -the `adserver_mock` mediation request, and the full Prebid Server response -(including its `ext.debug` echo of the resolved request) when a provider sets -`profile_config.debug = true`. Enable it only for short local debugging -sessions. +the request to the ad server, and the full Prebid Server response (including +its `ext.debug` echo of the resolved request) when the `[demand.]` table +of a Prebid Server demand source sets `debug = true`. Enable it only for short +local debugging sessions. ## Runtime boundaries diff --git a/docs/guide/cli.md b/docs/guide/cli.md index b9040dea24..c0b10aa495 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -354,13 +354,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: @@ -725,7 +726,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"] @@ -747,7 +751,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 97cb55004c..196e8a793f 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 @@ -49,7 +62,7 @@ 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__MODULE=hmac export TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase @@ -66,13 +79,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 @@ -102,26 +116,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: @@ -145,8 +159,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: @@ -206,33 +221,40 @@ fail and the service will return its startup-error response. ## 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 | -| `[device]` | Device classification module selection | -| `[ec]` | Edge Cookie identity, persistence, and partner sync | -| `[geo]` | Geolocation module selection | -| `[[handlers]]` | Ordered HTTP Basic-auth rules | -| `[image_optimizer]` | Reusable Fastly Image Optimizer profiles | -| `[integrations.*]` | Typed partner and browser integration settings | -| `[permission-signal]` | Permission signal schemes and the order they run in | -| `[permissions]` | The publisher's permission policy file, by name | -| `[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 | +| `[permission-signal]` | several modules | Which permission signals are acted on, in order | +| `[permissions]` | nothing | The publisher's permission policy file, by name | +| `[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 @@ -256,8 +278,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" @@ -265,21 +286,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 @@ -312,20 +334,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 @@ -464,7 +487,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**: @@ -488,7 +511,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. @@ -660,31 +683,49 @@ Legacy consent-store records are not read or migrated into `ec.ec_store`. Their ### `[ec]` -| Field | Type | Required | Description | -| ------------------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `module` | String or null | No | Name of the active Edge Cookie module: `"hmac"` (built-in), `"host_signals"` (opt-in), or `"none"` (explicitly stateless). Omit to run statelessly with no Edge Cookie. The `"client_fixed"` demo 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; cluster rechecks no longer use timestamps | -| `partners` | Array | No | Static partner registry entries | +| 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. -Each module that has settings is configured in its own `[ec.]` block, and the `module` selector names which block is active. A block may set `implementation = ""` to say which module it configures, which makes the block 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 `-`. +`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. -A module has a block 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 block fails at startup, while the `client_fixed` demonstration module needs no block at all. A block the selector does not name also fails at startup, so a stale block cannot sit unnoticed. +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, keyed `hmac`. +The built-in HMAC-over-client-IP module, named `hmac`. -`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. +`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. ::: @@ -726,9 +767,9 @@ These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environ #### `module` -**Purpose**: Names the active Edge Cookie module by its key. Omit to run statelessly with no Edge Cookie. +**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 implementation needs an `[ec.]` block it has no block for, or if a block the selector does not name is configured. +**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]`. #### `hmac.passphrase` @@ -736,13 +777,13 @@ These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environ **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 string +- Empty - Shorter than 32 characters ## Device Configuration @@ -751,11 +792,11 @@ Selects how a request is classified into the coarse device signals the Edge Cook ### `[device]` -| Field | Type | Required | Description | -| -------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `module` | String or null | No | Key of the device-detection module. Defaults to `builtin` (User-Agent only, no host-specific call). Set `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers | +| 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. Selecting an unknown module key fails at startup. +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**: @@ -778,10 +819,13 @@ Selects how a client IP is resolved into geolocation (country, region, coordinat | Field | Type | Required | Description | | ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `module` | String or null | No | Key of the geo module. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | +| `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`. | -No module is the default, so a default deployment is not tied to any host geo service. Selecting an unknown module key fails at startup. A failed geo lookup at request time resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. +`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**: @@ -796,6 +840,41 @@ module = "platform" 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. + +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. @@ -1154,7 +1233,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. @@ -1174,7 +1253,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. @@ -1299,6 +1378,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**: @@ -1319,7 +1399,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. @@ -1332,8 +1412,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`. @@ -1671,75 +1751,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 | @@ -1779,11 +1856,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 | @@ -1792,11 +1868,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 | @@ -1806,11 +1881,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 | @@ -1821,15 +1895,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 @@ -1840,11 +1916,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 | | ------------------- | ------- | --------- | --------------------------------------------------------- | @@ -1855,37 +1930,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` | @@ -1894,24 +1968,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 @@ -1920,52 +1996,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 @@ -1982,7 +2057,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 @@ -2015,33 +2090,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 ``` @@ -2049,42 +2125,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 | @@ -2096,8 +2163,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" @@ -2109,11 +2178,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 | @@ -2123,11 +2191,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 | @@ -2136,8 +2203,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 @@ -2145,10 +2214,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]` @@ -2158,9 +2229,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` @@ -2182,7 +2253,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. @@ -2208,42 +2279,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**: @@ -2253,108 +2333,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"] ``` @@ -2365,51 +2471,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]` @@ -2452,7 +2564,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". @@ -2462,7 +2574,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 }] ``` @@ -2696,9 +2808,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 @@ -2944,12 +3056,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. @@ -2996,7 +3108,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. @@ -3015,7 +3127,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` | @@ -3081,47 +3193,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**: -- `module`, when set, is a module name and has the `[ec.]` block its implementation needs, and no unselected block 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 -- 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 -**Handler Validation**: +**Secrets**: -- `path` is valid regex +- Every secret setting holds a key name and no secret value + +**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 a2321309f7..87122b4fac 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -43,7 +43,7 @@ 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: diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 87be81b8eb..56b69e0169 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -114,21 +114,21 @@ The `ec_identity_store` KV store is the only EC lifecycle store. It holds identi 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**. -- **Server-side** (for example the built-in HMAC module, or the built-in `host_signals` module that creates the 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` demo). 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 creates the identifier from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. +- **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(["Page request, no Edge Cookie"]) --> Type{"Module type"} - Type -->|"Server-side (e.g. HMAC)"| SGen["generate() creates the identifier at the edge from request data"] + 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 creates"] + 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"]) @@ -139,7 +139,7 @@ The two types differ only in route and in the methods they use: | Feature | Server-side | Client-side | | -------------------- | ------------------------- | ---------------------------------------------- | -| Example | HMAC (`hmac`) | `client_fixed` (demo) | +| 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 | @@ -148,7 +148,7 @@ The two types differ only in route and in the methods they use: 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 the 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. +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. 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. diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index c77e9e3e89..e63138ac93 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -126,15 +126,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" ``` @@ -150,13 +148,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 ``` @@ -212,9 +209,9 @@ TRUSTED_SERVER__EC__HMAC__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:** @@ -263,14 +260,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: @@ -325,14 +322,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 @@ -367,8 +364,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 ``` @@ -396,7 +395,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" ``` @@ -417,15 +419,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) @@ -704,18 +706,21 @@ cargo install viceroy --version 0.21.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/getting-started.md b/docs/guide/getting-started.md index d7761dde1f..aa8631a362 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -187,19 +187,20 @@ 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; -- your permission policy file under `[permissions] policy`, by its stem; -- 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]` +- your permission policy file under `[permissions] policy`, by its stem +- 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..d8ca298eef 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -130,10 +130,12 @@ impl PlatformBackend for FixtureBackend { struct FixtureGeo; +#[async_trait::async_trait(?Send)] impl PlatformGeo for FixtureGeo { - fn lookup( + async fn lookup( &self, _client_ip: Option, + _services: &RuntimeServices, ) -> Result, Report> { Ok(None) } @@ -212,12 +214,280 @@ 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 `