diff --git a/.github/workflows/aps-real-gam.yml b/.github/workflows/aps-real-gam.yml
new file mode 100644
index 000000000..71348d0e5
--- /dev/null
+++ b/.github/workflows/aps-real-gam.yml
@@ -0,0 +1,106 @@
+name: "APS real-GAM attestation"
+run-name: >-
+ APS real-GAM / ${{ inputs.evidence_id }} / ${{ inputs.release_id }}
+
+permissions:
+ contents: read
+
+on:
+ workflow_call:
+ inputs:
+ release_id:
+ description: Exact TSJS release id deployed to the protected test network
+ required: true
+ type: string
+ evidence_id:
+ description: Unique cutover evidence identifier
+ required: true
+ type: string
+ previous_artifact_id:
+ description: Immutable artifact identifier used for rollback
+ required: true
+ type: string
+ workflow_dispatch:
+ inputs:
+ release_id:
+ description: Exact TSJS release id deployed to the protected test network
+ required: true
+ type: string
+ evidence_id:
+ description: Unique cutover evidence identifier
+ required: true
+ type: string
+ previous_artifact_id:
+ description: Immutable artifact identifier used for rollback
+ required: true
+ type: string
+
+jobs:
+ attest:
+ name: Chromium, Firefox, and WebKit attestation
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ environment: aps-real-gam
+ env:
+ TS_REAL_GAM_PAGE_URL: ${{ secrets.TS_REAL_GAM_PAGE_URL }}
+ TS_REAL_GAM_AUTH_HEADER: ${{ secrets.TS_REAL_GAM_AUTH_HEADER }}
+ TS_REAL_GAM_EXPECTED_RELEASE_ID: ${{ vars.TS_REAL_GAM_EXPECTED_RELEASE_ID }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate protected inputs and release binding
+ env:
+ DISPATCH_EVIDENCE_ID: ${{ inputs.evidence_id }}
+ DISPATCH_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }}
+ DISPATCH_RELEASE_ID: ${{ inputs.release_id }}
+ run: bash scripts/ci/aps-real-gam.sh validate-inputs
+
+ - name: Read repository toolchain pins
+ id: toolchains
+ run: bash scripts/ci/read-toolchains.sh
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.toolchains.outputs.node }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-integration-tests/browser/package-lock.json
+
+ - name: Install isolated browser-test dependencies
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: npm ci
+
+ - name: Install all required browsers
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: npx playwright install --with-deps chromium firefox webkit
+
+ - name: Run protected real-GAM contract
+ id: real-gam
+ run: bash scripts/ci/aps-real-gam.sh run
+
+ - name: Write release attestation
+ if: always()
+ env:
+ EVIDENCE_ID: ${{ inputs.evidence_id }}
+ PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }}
+ RELEASE_ID: ${{ inputs.release_id }}
+ TEST_OUTCOME: ${{ steps.real-gam.outcome }}
+ run: node scripts/ci/aps-tsjs-evidence.mjs write-real-gam
+
+ - name: Scrub browser evidence before upload
+ if: always()
+ env:
+ TEST_OUTCOME: ${{ steps.real-gam.outcome }}
+ run: node scripts/ci/aps-tsjs-evidence.mjs scrub-real-gam
+
+ - name: Upload real-GAM evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: aps-real-gam-${{ github.run_id }}
+ path: |
+ crates/trusted-server-integration-tests/browser/real-gam-evidence/
+ crates/trusted-server-integration-tests/browser/playwright-report/
+ crates/trusted-server-integration-tests/browser/test-results/
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 4973afe44..75f854f9c 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -1,4 +1,6 @@
name: "Integration Tests"
+run-name: >-
+ Integration Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }}
permissions:
contents: read
@@ -9,6 +11,19 @@ on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
+ inputs:
+ evidence_id:
+ description: Unique identifier used to bind this run to an evidence artifact
+ required: true
+ type: string
+ release_id:
+ description: Exact generated TSJS release id
+ required: true
+ type: string
+ previous_artifact_id:
+ description: Immutable artifact identifier used for rollback
+ required: true
+ type: string
env:
ORIGIN_PORT: 8888
@@ -19,8 +34,31 @@ env:
CF_BUILD_ARTIFACT_PATH: /tmp/integration-test-artifacts/cloudflare/build
jobs:
+ tsjs-performance-gate:
+ name: TSJS first-display performance evidence
+ if: >-
+ github.event_name == 'workflow_dispatch' &&
+ (startsWith(inputs.evidence_id, 'aps-tsjs-preswitch-') ||
+ startsWith(inputs.evidence_id, 'aps-tsjs-postswitch-'))
+ uses: ./.github/workflows/tsjs-performance-gate.yml
+ with:
+ evidence_id: ${{ inputs.evidence_id }}
+ mode: ${{ startsWith(inputs.evidence_id, 'aps-tsjs-postswitch-') && 'postswitch' || 'preswitch' }}
+
+ real-gam-attestation:
+ name: protected real-GAM attestation
+ if: >-
+ github.event_name == 'workflow_dispatch' &&
+ startsWith(inputs.evidence_id, 'aps-tsjs-cutover-')
+ uses: ./.github/workflows/aps-real-gam.yml
+ with:
+ evidence_id: ${{ inputs.evidence_id }}
+ release_id: ${{ inputs.release_id }}
+ previous_artifact_id: ${{ inputs.previous_artifact_id }}
+
prepare-artifacts:
name: prepare integration artifacts
+ if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -57,6 +95,7 @@ jobs:
integration-tests:
name: integration tests
+ if: github.event_name == 'pull_request'
needs: prepare-artifacts
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -116,6 +155,7 @@ jobs:
integration-tests-fastly-ec:
name: integration tests (Fastly EC lifecycle)
+ if: github.event_name == 'pull_request'
needs: prepare-artifacts
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -152,10 +192,60 @@ jobs:
VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml
RUST_LOG: info
+ aps-runner-proxy:
+ name: APS runner proxy (${{ matrix.runtime }})
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ runtime: [axum, fastly, cloudflare, spin]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up APS proxy test environment
+ id: shared-setup
+ uses: ./.github/actions/setup-integration-test-env
+ with:
+ origin-port: ${{ env.ORIGIN_PORT }}
+ install-viceroy: ${{ matrix.runtime == 'fastly' && 'true' || 'false' }}
+ build-wasm: "false"
+ build-axum: "false"
+ build-test-images: "false"
+ build-cloudflare: "false"
+
+ - name: Add Cloudflare wasm target
+ if: matrix.runtime == 'cloudflare'
+ run: rustup target add wasm32-unknown-unknown
+
+ - name: Set up Node.js for Wrangler
+ if: matrix.runtime == 'cloudflare'
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.shared-setup.outputs.node-version }}
+
+ - name: Install Wrangler
+ if: matrix.runtime == 'cloudflare'
+ run: npm install -g wrangler@4.64.0
+
+ - name: Install Spin
+ if: matrix.runtime == 'spin'
+ uses: fermyon/actions/spin/setup@v1
+ with:
+ version: "v4.0.2"
+
+ - name: Run actual-adapter APS runner-proxy corpus
+ run: ./scripts/integration-tests-aps-runner-proxy.sh --runtime ${{ matrix.runtime }}
+ env:
+ INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
+ RUST_LOG: info
+
browser-tests:
name: browser integration tests
+ if: github.event_name == 'pull_request'
needs: prepare-artifacts
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
@@ -244,3 +334,143 @@ jobs:
name: playwright-traces
path: crates/trusted-server-integration-tests/browser/test-results/
retention-days: 7
+
+ browser-tests-aps-tsjs-conformance:
+ name: browser integration tests (APS/TSJS conformance)
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up APS/TSJS browser test runtime
+ id: shared-setup
+ uses: ./.github/actions/setup-integration-test-env
+ with:
+ origin-port: ${{ env.ORIGIN_PORT }}
+ install-viceroy: "true"
+ build-wasm: "false"
+ build-axum: "false"
+ build-test-images: "false"
+ build-cloudflare: "false"
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.shared-setup.outputs.node-version }}
+ cache: npm
+ cache-dependency-path: |
+ crates/trusted-server-integration-tests/browser/package-lock.json
+ crates/trusted-server-js/lib/package-lock.json
+
+ - name: Run focused APS/TSJS three-browser conformance matrix
+ env:
+ INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
+ TS_BROWSER_FRAMEWORKS: nextjs
+ TS_BROWSER_PROJECTS: chromium,firefox,webkit
+ run: ./scripts/integration-tests-browser.sh tests/shared/aps-renderer.spec.ts tests/shared/aps-puc-lifecycle.spec.ts tests/shared/tsjs-runtime.spec.ts tests/shared/creative-sandbox.spec.ts tests/nextjs/gpt-diagnostics.spec.ts tests/nextjs/navigation.spec.ts --project=chromium --project=firefox --project=webkit
+
+ - name: Upload APS/TSJS Playwright report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report-aps-tsjs-conformance
+ path: crates/trusted-server-integration-tests/browser/playwright-report/
+ retention-days: 7
+
+ cutover-suite:
+ name: exact APS/TSJS integration evidence
+ if: github.event_name == 'workflow_dispatch' && startsWith(inputs.evidence_id, 'aps-tsjs-cutover-')
+ runs-on: ubuntu-24.04
+ timeout-minutes: 120
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up the complete integration environment
+ id: shared-setup
+ uses: ./.github/actions/setup-integration-test-env
+ with:
+ origin-port: ${{ env.ORIGIN_PORT }}
+ install-viceroy: "true"
+ build-cloudflare: "true"
+
+ - name: Set up pinned Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.shared-setup.outputs.node-version }}
+ cache: npm
+ cache-dependency-path: |
+ crates/trusted-server-js/lib/package-lock.json
+ crates/trusted-server-integration-tests/browser/package-lock.json
+
+ - name: Install exact integration runtimes
+ run: npm install -g wrangler@4.64.0
+
+ - name: Install pinned Spin
+ uses: fermyon/actions/spin/setup@v1
+ with:
+ version: "v4.0.2"
+
+ - name: Generate integration Viceroy configs
+ run: ./scripts/generate-integration-viceroy-configs.sh
+ env:
+ INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
+
+ - name: Build and validate the exact TSJS release
+ env:
+ EXPECTED_RELEASE_ID: ${{ inputs.release_id }}
+ shell: bash
+ run: bash scripts/ci/aps-tsjs-cutover.sh build-release
+
+ - name: Run route parity and the full adapter integration suite
+ env:
+ WASM_BINARY_PATH: ${{ github.workspace }}/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm
+ AXUM_BINARY_PATH: ${{ github.workspace }}/target/debug/trusted-server-axum
+ CLOUDFLARE_WRANGLER_DIR: ${{ github.workspace }}/crates/trusted-server-adapter-cloudflare
+ INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
+ VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml
+ RUST_LOG: info
+ shell: bash
+ run: bash scripts/ci/aps-tsjs-cutover.sh run-adapters
+
+ - name: Install Chromium, Firefox, and WebKit
+ run: bash scripts/ci/aps-tsjs-cutover.sh install-browsers
+
+ - name: Run the focused three-browser APS/TSJS matrix
+ env:
+ WASM_BINARY_PATH: ${{ github.workspace }}/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm
+ INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
+ VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml
+ TEST_FRAMEWORK: nextjs
+ TS_BROWSER_PROJECTS: chromium,firefox,webkit
+ shell: bash
+ run: bash scripts/ci/aps-tsjs-cutover.sh run-browser
+
+ - name: Run the APS runner-proxy corpus on every adapter
+ shell: bash
+ run: bash scripts/ci/aps-tsjs-cutover.sh run-proxies
+ env:
+ INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
+ RUST_LOG: info
+
+ - name: Write exact integration evidence manifest
+ env:
+ EVIDENCE_ID: ${{ inputs.evidence_id }}
+ RELEASE_ID: ${{ inputs.release_id }}
+ PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }}
+ run: node scripts/ci/aps-tsjs-evidence.mjs write-integration
+
+ - name: Scrub all integration evidence before upload
+ env:
+ INTEGRATION_AUTHORIZATION: integration-test-proxy-secret
+ run: node scripts/ci/aps-tsjs-evidence.mjs scrub-integration
+
+ - name: Upload exact integration evidence
+ uses: actions/upload-artifact@v4
+ with:
+ name: aps-tsjs-cutover-${{ github.sha }}
+ path: target/aps-tsjs-cutover-evidence/
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fb0233b03..20b280164 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,4 +1,6 @@
name: "Run Tests"
+run-name: >-
+ Run Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }}
permissions:
contents: read
@@ -7,6 +9,16 @@ on:
push:
branches: [main]
pull_request:
+ workflow_dispatch:
+ inputs:
+ evidence_id:
+ description: Unique cutover evidence identifier
+ required: true
+ type: string
+ release_id:
+ description: Exact generated TSJS release id
+ required: true
+ type: string
jobs:
test-rust:
@@ -226,6 +238,8 @@ jobs:
working-directory: crates/trusted-server-js/lib
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- name: Retrieve Node.js version
id: node-version
@@ -246,5 +260,94 @@ jobs:
- name: Build bundle
run: npm run build
+ - name: Build pure external Prebid artifact
+ run: npm run build:prebid-external
+
+ - name: Verify release inventory
+ run: npm run test:release
+
+ - name: Enforce bundle budgets
+ run: npm run check:bundle
+
+ - name: Typecheck full TSJS package
+ run: npm run typecheck
+
+ - name: Lint full TSJS package
+ run: npm run lint
+
+ - name: Verify generated APS renderer contract
+ run: npm run check:aps-contract
+
+ - name: Enforce hard-cutover absence
+ run: npm run check:hard-cutover-absence
+
+ - name: Run embedded APS renderer contract
+ run: node --test test/contract/aps-renderer-es5.test.mjs
+
+ - name: Verify retired concept audit
+ run: npm run check:concept-audit
+
- name: Run unit tests
run: npm test -- --run
+
+ cutover-quality-evidence:
+ name: APS/TSJS cutover quality evidence
+ if: github.event_name == 'workflow_dispatch'
+ needs:
+ - test-rust
+ - test-axum
+ - test-cloudflare
+ - test-spin
+ - test-parity
+ - test-cli
+ - test-typescript
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Read repository toolchain pins
+ id: toolchains
+ shell: bash
+ run: bash scripts/ci/read-toolchains.sh
+
+ - name: Set up pinned Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.toolchains.outputs.node }}
+ cache: npm
+ cache-dependency-path: |
+ crates/trusted-server-js/lib/package-lock.json
+ docs/package-lock.json
+
+ - name: Set up pinned Rust quality targets
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: ${{ steps.toolchains.outputs.rust }}
+ components: clippy, rustfmt
+ target: wasm32-wasip1,wasm32-unknown-unknown
+ cache-shared-key: cargo-${{ runner.os }}-aps-tsjs-quality
+
+ - name: Install exact JavaScript dependencies
+ run: bash scripts/ci/aps-tsjs-quality.sh install
+
+ - name: Validate release id and run final quality gates
+ env:
+ EXPECTED_RELEASE_ID: ${{ inputs.release_id }}
+ shell: bash
+ run: bash scripts/ci/aps-tsjs-quality.sh run
+
+ - name: Write exact quality evidence manifest
+ env:
+ EVIDENCE_ID: ${{ inputs.evidence_id }}
+ RELEASE_ID: ${{ inputs.release_id }}
+ run: node scripts/ci/aps-tsjs-evidence.mjs write-quality
+
+ - name: Upload exact quality evidence
+ uses: actions/upload-artifact@v4
+ with:
+ name: aps-tsjs-quality-${{ github.run_id }}
+ path: target/aps-tsjs-quality-evidence/
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.github/workflows/tsjs-performance-gate.yml b/.github/workflows/tsjs-performance-gate.yml
new file mode 100644
index 000000000..309943f94
--- /dev/null
+++ b/.github/workflows/tsjs-performance-gate.yml
@@ -0,0 +1,128 @@
+name: "TSJS Performance Gate"
+run-name: >-
+ TSJS Performance Gate / ${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }} / ${{ inputs.mode || 'pull-request' }}
+
+permissions:
+ contents: read
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/tsjs-performance-gate.yml"
+ - ".tool-versions"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "crates/trusted-server-core/**"
+ - "crates/trusted-server-core/src/auction/**"
+ - "crates/trusted-server-core/src/html_processor.rs"
+ - "crates/trusted-server-core/src/publisher.rs"
+ - "crates/trusted-server-core/src/tsjs.rs"
+ - "crates/trusted-server-integration-tests/Cargo.toml"
+ - "crates/trusted-server-integration-tests/browser/**"
+ - "crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs"
+ - "crates/trusted-server-js/**"
+ - "scripts/ci/read-toolchains.sh"
+ - "scripts/ci/tsjs-performance.sh"
+ - "scripts/validate-tsjs-performance-evidence.mjs"
+ workflow_dispatch:
+ inputs:
+ evidence_id:
+ description: Unique identifier bound to the uploaded evidence
+ required: true
+ type: string
+ mode:
+ description: Cutover side measured by this run
+ required: true
+ type: choice
+ options:
+ - preswitch
+ - postswitch
+ workflow_call:
+ inputs:
+ evidence_id:
+ description: Unique identifier bound to the uploaded evidence
+ required: true
+ type: string
+ mode:
+ description: Cutover side measured by this run (preswitch or postswitch)
+ required: true
+ type: string
+
+env:
+ TSJS_PERF_MACHINE_CLASS: github-hosted:ubuntu-24.04
+ TSJS_PERF_RUNNER_IMAGE: ubuntu-24.04
+ TSJS_PERF_WORKFLOW_NAME: TSJS Performance Gate
+ TSJS_PERF_WORKFLOW_FILE: .github/workflows/tsjs-performance-gate.yml
+ TSJS_PERF_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+
+jobs:
+ measure:
+ name: measure ${{ inputs.mode || 'pull-request' }} (${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }})
+ runs-on: ubuntu-24.04
+ timeout-minutes: 40
+ env:
+ TSJS_EVIDENCE_ID: ${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }}
+ TSJS_PERF_MODE: ${{ inputs.mode || 'pull-request' }}
+ TSJS_PERF_ARTIFACT_NAME: tsjs-performance-${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }}
+ TSJS_PERF_OUTPUT: crates/trusted-server-integration-tests/browser/test-results/tsjs-performance-${{ inputs.mode || 'pull-request' }}.json
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Validate immutable measurement inputs
+ shell: bash
+ run: bash scripts/ci/tsjs-performance.sh validate-inputs
+
+ - name: Read repository toolchain pins
+ id: toolchains
+ shell: bash
+ run: bash scripts/ci/read-toolchains.sh
+
+ - name: Set up pinned Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.toolchains.outputs.node }}
+ cache: npm
+ cache-dependency-path: |
+ crates/trusted-server-js/lib/package-lock.json
+ crates/trusted-server-integration-tests/browser/package-lock.json
+
+ - name: Set up pinned Rust for the generated controller fixture
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: ${{ steps.toolchains.outputs.rust }}
+ cache-shared-key: cargo-${{ runner.os }}-tsjs-performance
+
+ - name: Verify installed toolchain pins
+ shell: bash
+ run: bash scripts/ci/tsjs-performance.sh verify-toolchains
+
+ - name: Build the real TSJS artifacts once
+ run: bash scripts/ci/tsjs-performance.sh build-candidate
+
+ - name: Build the exact current main artifacts
+ shell: bash
+ run: bash scripts/ci/tsjs-performance.sh build-main
+
+ - name: Install the lockfile-pinned Chromium setup
+ run: bash scripts/ci/tsjs-performance.sh install-browser
+
+ - name: Run the complete TSJS performance sample exactly once
+ env:
+ CI: "true"
+ GITHUB_SHA: ${{ env.TSJS_PERF_HEAD_SHA }}
+ run: bash scripts/ci/tsjs-performance.sh run-sample
+
+ - name: Validate the generated evidence before upload
+ if: always()
+ run: bash scripts/ci/tsjs-performance.sh validate-evidence
+
+ - name: Upload immutable TSJS performance evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ env.TSJS_PERF_ARTIFACT_NAME }}
+ path: ${{ env.TSJS_PERF_OUTPUT }}
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.tool-versions b/.tool-versions
index 758146800..5330e3de6 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,5 +1,5 @@
fastly 15.1.0
rust 1.95.0
nodejs 24.12.0
-viceroy 0.17.0
+viceroy 0.19.0
wasmtime 44.0.1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c00487769..20cf4f781 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,15 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape.
-- **Breaking** — All auction paths now forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the existing Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters.
+- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration accepts only canonical `account_id`, no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, remove `pub_id`, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry a typed render source instead of executable markup on the public browser wire.
+- **Breaking** — All auction paths forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters.
+- Publisher HTML now uses `Cache-Control: max-age=60` when server-side ad templates are inactive, while preserving origin `private`/`no-store` policies and CDN-specific cache headers. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers.
- **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries.
- **Breaking** — Integration configuration strings are no longer globally reinterpreted as JSON scalars. Operators upgrading should audit `[integrations.*]` settings and use native TOML/typed-config booleans and numbers (for example, `enabled = true`, not `enabled = "true"`); quoted numeric and boolean scalars now fail validation instead of silently converting.
- **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading.
-- **Breaking** — Auction creative sanitization is now opt-in: the new `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives (the majority of programmatic display) while recording normal impressions. `[auction].rewrite_creatives` keeps its `true` default. The per-creative cap is now enforced on rewritten output as well as raw input and in every processing mode (1 MiB for auction `adm`; proxied HTML documents keep the proxy's own 10 MiB bound), rewriting fails closed on parser errors instead of emitting partial output and never turns a rejected creative into a runtime-only `adm`, and `hb_cache_host`/`hb_cache_path` are emitted only for bids that supplied no creative — any bid carrying its own `adm` ships without them, so a processed or rejected creative can never be re-fetched raw from PBS Cache. Creative markup with no `
` token now receives the click-guard runtime, and bidder ` ` elements are stripped whenever rewriting is enabled. The creative iframe sandbox no longer grants `allow-same-origin`, restoring origin isolation; rewritten-click recovery from the resulting opaque-origin iframe uses the GET `/first-party/proxy-rebuild` navigation fallback, now registered in every adapter and documented alongside the POST JSON form. Inside those iframes, dynamic resource signing and CORS-mode subresources (ES modules, `crossorigin` fonts) are unavailable pending the constrained asset capability in [#982](https://github.com/IABTechLab/trusted-server/issues/982); ordinary image, script, and stylesheet loads are unaffected. Upgrading: binaries that predate `sanitize_creatives` reject a blob carrying it, so upgrade the binary first, then push the config. Rollback: non-default values (`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized into the config blob and older binaries reject unknown fields — before rolling back to a binary that predates a field, restore its default, push the default-compatible blob, then roll back.
-- The SPA re-auction endpoint moved from `/__ts/page-bids` to `/_ts/page-bids`, joining every other internal route in the `/_ts/` namespace. The old path stays registered as a deprecated alias so already-loaded bundles keep serving ads, and responses on it carry a `Link: …; rel="deprecation"` header so remaining traffic is measurable from edge logs; removal is tracked in [#970](https://github.com/IABTechLab/trusted-server/issues/970). Two deployment notes: audit `[[handlers]]` for patterns broad enough to cover `/_ts` (for example `^/_ts`), which would put this browser-facing endpoint behind Basic Auth and return `401` to every visitor — scope them to `^/_ts/admin`; and prefer rolling forward over rolling back, since a server reverted past this release does not register the canonical path. In both cases the shipped client falls back to the deprecated alias, so the exposure is bounded until that alias is removed.
+- **Breaking** — Auction creative sanitization is now opt-in: `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives while recording normal impressions; `[auction].rewrite_creatives` stays default-on. The auction `adm` cap is 1 MiB while full proxied HTML keeps the proxy's 10 MiB bound. Rewriting now fails closed on parser or output-limit errors, never resurrects rejected markup as a runtime-only `adm`, removes bidder ` ` elements, and injects the click guard into body-less fragments. Creative frames omit `allow-same-origin`; mutated clicks recover through GET or form-POST `/first-party/proxy-rebuild` navigation. The generic proxy strips upstream CORS grants, so opaque frames cannot read proxied bodies; CORS-mode subresources and dynamic resource signing remain unavailable pending [#982](https://github.com/IABTechLab/trusted-server/issues/982), while ordinary image, script, stylesheet, and media loads are unaffected.
+- **Breaking** — The SPA re-auction endpoint is `/_ts/page-bids`. The removed `/__ts/page-bids` spelling is an unknown route; update handler rules and callers at cutover.
- Added optional APS `inventory_domain` and `inventory_page_origin` overrides for deployments whose edge hostname differs from the APS-authorized inventory identity.
-- Preserved APS renderer capabilities through the client-side `trustedServer` Prebid adapter, allowing its generated `hb_adid` to render through GAM and Prebid Universal Creative instead of producing an empty creative.
+- APS/ADM Prebid delivery now crosses Prebid normalization using only standard `adId` plus per-bid `meta` identity; executable renderer sources remain in the bounded server-owned reservation and cannot be stripped with an unknown top-level bid field.
### Security
@@ -26,10 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected.
-- Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative.
+- The canonical browser auction projection now rejects blank upstream bid IDs per winner, uses a server-minted renderer reservation as the sole GAM render identity, and emits cache coordinates only as part of a validated cache render source. This replaces the legacy `window.tsjs.bids` `hb_adid` fallback chain.
### Added
+- Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape.
+- Added typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint.
- Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder ` ` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery.
- `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup rejects a blank `gam_network_id` only when an absent/default path or `{network_id}` template consumes it. Trusted Server conservatively caps whole rendered dynamic paths at 100 UTF-8 bytes, informed by Google's 100-character per-ad-unit-code limit; an over-limit request-specific path omits that slot without failing the response. During typed/startup finalization, every placeholder-bearing template that omits `section_segment` materializes `section_segment = 0`, so an older binary rejects the blob loudly. Static and absent paths remain legacy-schema compatible only when both `section_root` and `section_segment` are omitted. Before rolling back below this feature, replace or remove dynamic paths, remove both keys, re-push and finalize the config, then roll back the binary.
- Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape.
diff --git a/CLAUDE.md b/CLAUDE.md
index 546a3bf52..dd1c4041f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,7 @@ Supporting files: `edgezero.toml`, `fastly.toml`,
| WASM target | `wasm32-wasip1` |
| Node | 24.12.0 (from `.tool-versions`) |
| Fastly CLI | 15.1.0 (from `.tool-versions`) |
-| Viceroy | 0.17.0 (from `.tool-versions`) |
+| Viceroy | 0.19.0 (from `.tool-versions`) |
| Wasmtime | 44.0.1 (from `.tool-versions`) |
---
@@ -139,7 +139,7 @@ cd crates/trusted-server-js/lib && node build-all.mjs
### Install prerequisites
```bash
-cargo install viceroy --version 0.17.0 --locked --force
+cargo install viceroy --version 0.19.0 --locked --force
```
---
diff --git a/Cargo.lock b/Cargo.lock
index cb8f40c68..159548088 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5287,6 +5287,7 @@ dependencies = [
"trusted-server-core",
"url",
"urlencoding",
+ "web-time",
]
[[package]]
@@ -5415,6 +5416,7 @@ dependencies = [
"reqwest 0.12.28",
"scraper",
"serde_json",
+ "tempfile",
"testcontainers",
"tokio",
"toml",
@@ -5432,6 +5434,8 @@ version = "0.1.0"
dependencies = [
"build-print",
"hex",
+ "serde",
+ "serde_json",
"sha2 0.10.9",
"which",
]
diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml
index 15b6ee59d..ab9a72942 100644
--- a/crates/trusted-server-adapter-axum/Cargo.toml
+++ b/crates/trusted-server-adapter-axum/Cargo.toml
@@ -18,8 +18,13 @@ path = "src/lib.rs"
name = "trusted-server-axum"
path = "src/main.rs"
+[features]
+default = []
+aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"]
+
[dependencies]
async-trait = { workspace = true }
+axum = { workspace = true }
edgezero-adapter-axum = { workspace = true, features = ["axum"] }
edgezero-core = { workspace = true }
error-stack = { workspace = true }
@@ -31,7 +36,6 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti
trusted-server-core = { workspace = true }
[dev-dependencies]
-axum = { workspace = true }
base64 = { workspace = true }
temp-env = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs
index 1bed830ac..01ce83bc6 100644
--- a/crates/trusted-server-adapter-axum/src/app.rs
+++ b/crates/trusted-server-adapter-axum/src/app.rs
@@ -19,8 +19,8 @@ use trusted_server_core::proxy::{
handle_first_party_proxy_sign,
};
use trusted_server_core::publisher::{
- AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async,
- handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied,
+ AuctionDispatch, PAGE_BIDS_PATH, buffer_publisher_response_async, handle_page_bids,
+ handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied,
};
use trusted_server_core::request_signing::{
handle_trusted_server_discovery, handle_verify_signature,
@@ -29,6 +29,7 @@ use trusted_server_core::settings::Settings;
use trusted_server_core::settings_data::{
default_config_key, default_config_store_name, get_settings_from_config_store,
};
+use trusted_server_core::trace_cookie::handle_trace_mode;
use trusted_server_core::platform::RuntimeServices;
@@ -79,6 +80,91 @@ fn build_state_with_settings(
}))
}
+async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option {
+ if !state.registry.has_reserved_path(req.uri().path()) {
+ return None;
+ }
+ let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default());
+ let services = build_runtime_services(&ctx);
+ Some(
+ state
+ .registry
+ .handle_reserved_proxy(&state.settings, &services, ctx.into_request())
+ .await
+ .expect("reserved path should have a hard-cutover handler")
+ .unwrap_or_else(|report| http_error(&report)),
+ )
+}
+
+#[derive(Clone)]
+/// Dispatcher that owns one startup-built registry for hard-cutover route families.
+pub struct ReservedApsDispatcher {
+ state: Arc,
+}
+
+impl ReservedApsDispatcher {
+ /// Build the dispatcher from the adapter's startup settings.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error when settings, the orchestrator, or the integration
+ /// registry cannot be initialized.
+ pub fn from_startup_settings() -> Result> {
+ // The outer Axum router cannot share EdgeZero's private application
+ // state, so the dev adapter builds one additional immutable startup
+ // snapshot for only the two reserved APS browser resources. Production
+ // adapters do not take this native development path.
+ Ok(Self {
+ state: build_state()?,
+ })
+ }
+
+ /// Build the dispatcher from explicit settings.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error when the orchestrator or integration registry cannot be
+ /// initialized from `settings`.
+ pub fn from_settings(settings: Settings) -> Result> {
+ Ok(Self {
+ state: build_state_with_settings(settings)?,
+ })
+ }
+
+ /// Dispatch a request when it belongs to the reserved APS family.
+ pub async fn dispatch(&self, req: Request) -> Option {
+ dispatch_reserved_for_state(&self.state, req).await
+ }
+}
+
+/// Dispatch a reserved APS request using explicit settings.
+///
+/// # Errors
+///
+/// Returns an error when the dispatcher cannot be initialized.
+pub async fn dispatch_reserved_with_settings(
+ settings: Settings,
+ req: Request,
+) -> Result, Report> {
+ Ok(ReservedApsDispatcher::from_settings(settings)?
+ .dispatch(req)
+ .await)
+}
+
+/// Dispatch a reserved APS request using startup settings.
+///
+/// # Errors
+///
+/// Returns an error when startup settings or the dispatcher
+/// cannot be initialized.
+pub async fn dispatch_reserved(
+ req: Request,
+) -> Result, Report> {
+ Ok(ReservedApsDispatcher::from_startup_settings()?
+ .dispatch(req)
+ .await)
+}
+
// ---------------------------------------------------------------------------
// Error helper
// ---------------------------------------------------------------------------
@@ -182,7 +268,7 @@ async fn dispatch_fallback(
let path = req.uri().path().to_string();
let method = req.method().clone();
- if method == Method::GET && path.starts_with("/static/tsjs=") {
+ if path.starts_with("/static/tsjs=") {
return handle_tsjs_dynamic(&req, &state.registry);
}
@@ -262,6 +348,7 @@ enum NamedRouteHandler {
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
/// reach the publisher fallback (which would leak admin credentials).
LegacyAdminDenied,
+ TraceMode,
Auction,
PageBids,
FirstPartyProxy,
@@ -286,7 +373,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];
-fn named_routes() -> [NamedRoute; 13] {
+fn named_routes() -> [NamedRoute; 14] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
@@ -327,6 +414,11 @@ fn named_routes() -> [NamedRoute; 13] {
primary_methods: LEGACY_ADMIN_DENY_METHODS,
handler: NamedRouteHandler::LegacyAdminDenied,
},
+ NamedRoute {
+ path: "/_ts/trace",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::TraceMode,
+ },
NamedRoute {
path: "/auction",
primary_methods: &[Method::POST],
@@ -339,13 +431,12 @@ fn named_routes() -> [NamedRoute; 13] {
primary_methods: &[Method::GET, Method::OPTIONS],
handler: NamedRouteHandler::PageBids,
},
- // Deprecated double-underscore alias, kept so tsjs bundles served before
- // the `/_ts/page-bids` rename keep getting ads on SPA navigations until
- // they age out of browser caches. See `PAGE_BIDS_LEGACY_PATH`.
+ // This removed route must never reach the publisher fallback, which
+ // would make the hard cutover depend on the origin response.
NamedRoute {
- path: PAGE_BIDS_LEGACY_PATH,
- primary_methods: &[Method::GET, Method::OPTIONS],
- handler: NamedRouteHandler::PageBids,
+ path: "/__ts/page-bids",
+ primary_methods: LEGACY_ADMIN_DENY_METHODS,
+ handler: NamedRouteHandler::LegacyAdminDenied,
},
NamedRoute {
path: "/first-party/proxy",
@@ -408,6 +499,9 @@ fn named_route_handler(
Ok(resp)
}
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
+ NamedRouteHandler::TraceMode => {
+ handle_trace_mode(&state.settings, req.uri().query())
+ }
NamedRouteHandler::Auction => {
// Build the geo-aware EC context so the auction consent
// gate sees the caller's jurisdiction — `EcContext::default()`
diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs
index 960982176..2899a22ac 100644
--- a/crates/trusted-server-adapter-axum/src/main.rs
+++ b/crates/trusted-server-adapter-axum/src/main.rs
@@ -1,9 +1,14 @@
-use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig};
+use edgezero_adapter_axum::dev_server::AxumDevServerConfig;
use edgezero_core::app::Hooks as _;
use trusted_server_adapter_axum::app::TrustedServerApp;
+#[tokio::main]
#[allow(clippy::print_stderr)]
-fn main() {
+async fn main() {
+ use axum::Router;
+ use axum::routing::any;
+ use edgezero_adapter_axum::service::EdgeZeroAxumService;
+
if let Err(e) = simple_logger::SimpleLogger::new().init() {
eprintln!("warning: logger init failed: {e}");
}
@@ -19,11 +24,65 @@ fn main() {
None => AxumDevServerConfig::default(),
};
+ let dispatcher =
+ trusted_server_adapter_axum::app::ReservedApsDispatcher::from_startup_settings()
+ .expect("should build the reserved APS dispatcher");
+ let reserved = any(move |request: axum::http::Request| {
+ let dispatcher = dispatcher.clone();
+ async move {
+ // The core reserved dispatcher is intentionally `?Send`, while this
+ // native-only development adapter runs on Tokio's multi-threaded
+ // executor. Keep that bridge explicit: a runner request can occupy
+ // this blocking-pool thread for its bounded five-second budget.
+ let response = tokio::task::block_in_place(|| {
+ tokio::runtime::Handle::current().block_on(async move {
+ let request =
+ match edgezero_adapter_axum::request::into_core_request(request).await {
+ Ok(request) => request,
+ Err(error) => {
+ log::warn!("reserved APS request conversion failed: {error:?}");
+ return Err(axum::http::StatusCode::BAD_REQUEST);
+ }
+ };
+ match dispatcher.dispatch(request).await {
+ Some(response) => Ok(response),
+ None => {
+ log::error!(
+ "reserved APS entry route reached a request outside its family"
+ );
+ Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
+ }
+ }
+ })
+ });
+ match response {
+ Ok(response) => edgezero_adapter_axum::response::into_axum_response(response),
+ Err(status) => axum::response::IntoResponse::into_response(status),
+ }
+ }
+ });
+ let app = Router::new()
+ .route("/integrations/aps", reserved.clone())
+ .route("/integrations/aps/{*rest}", reserved)
+ .fallback_service(EdgeZeroAxumService::new(TrustedServerApp::routes()));
+ let listener = tokio::net::TcpListener::bind(config.addr)
+ .await
+ .expect("should bind the configured address");
log::info!("Listening on http://{}", config.addr);
- let router = TrustedServerApp::routes();
- if let Err(err) = AxumDevServer::with_config(router, config).run() {
- log::error!("trusted-server-adapter-axum failed: {err}");
- std::process::exit(1);
+ let server = axum::serve(listener, app);
+ let result = if config.enable_ctrl_c {
+ server
+ .with_graceful_shutdown(async {
+ if let Err(error) = tokio::signal::ctrl_c().await {
+ log::error!("failed to install Ctrl-C handler: {error}");
+ }
+ })
+ .await
+ } else {
+ server.await
+ };
+ if let Err(error) = result {
+ log::error!("trusted-server-adapter-axum failed: {error}");
}
}
diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs
index a511daab2..a44ceb147 100644
--- a/crates/trusted-server-adapter-axum/src/platform.rs
+++ b/crates/trusted-server-adapter-axum/src/platform.rs
@@ -11,7 +11,8 @@ use error_stack::{Report, ResultExt as _};
use trusted_server_core::platform::{
ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError,
PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse,
- PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName,
+ PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1,
+ RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId, StoreName,
};
// ---------------------------------------------------------------------------
@@ -285,6 +286,9 @@ pub struct AxumPlatformHttpClient {
client: reqwest::Client,
}
+#[cfg(feature = "aps-runner-proxy-integration-test")]
+const APS_RUNNER_PROXY_TEST_ENDPOINT_ENV: &str = "TS_APS_RUNNER_PROXY_TEST_ENDPOINT";
+
impl AxumPlatformHttpClient {
/// Create a new client with sensible dev-server timeouts.
///
@@ -307,6 +311,38 @@ impl AxumPlatformHttpClient {
}
}
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ fn aps_runner_proxy_test_transport_uri(
+ logical_uri: &str,
+ ) -> Result, Report> {
+ use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL;
+
+ if logical_uri != APS_RUNNER_UPSTREAM_URL {
+ return Ok(None);
+ }
+ let endpoint = std::env::var(APS_RUNNER_PROXY_TEST_ENDPOINT_ENV).map_err(|_| {
+ Report::new(PlatformError::HttpClient).attach(
+ "APS runner proxy integration artifact requires its loopback fixture endpoint",
+ )
+ })?;
+ let parsed = reqwest::Url::parse(&endpoint)
+ .change_context(PlatformError::HttpClient)
+ .attach("invalid APS runner proxy integration fixture endpoint")?;
+ if parsed.scheme() != "http"
+ || !matches!(parsed.host_str(), Some("127.0.0.1" | "::1"))
+ || parsed.port().is_none()
+ || !parsed.username().is_empty()
+ || parsed.password().is_some()
+ || parsed.query().is_some()
+ || parsed.fragment().is_some()
+ {
+ return Err(Report::new(PlatformError::HttpClient).attach(
+ "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL",
+ ));
+ }
+ Ok(Some(parsed.into()))
+ }
+
/// Drain `body` to a `Vec`.
///
/// For `Body::Stream` this awaits every chunk in the current async context
@@ -380,6 +416,127 @@ impl AxumPlatformHttpClient {
Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name))
}
+
+ fn raw_header_evidence(
+ headers: &reqwest::header::HeaderMap,
+ name: reqwest::header::HeaderName,
+ ) -> ProxyHeaderEvidenceV1 {
+ ProxyHeaderEvidenceV1::Occurrences(
+ headers
+ .get_all(name)
+ .iter()
+ .map(|value| value.as_bytes().to_vec())
+ .collect(),
+ )
+ }
+
+ fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option {
+ let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else {
+ return None;
+ };
+ let [value] = values.as_slice() else {
+ return None;
+ };
+ if value.is_empty()
+ || !value.iter().all(u8::is_ascii_digit)
+ || (value.len() > 1 && value[0] == b'0')
+ {
+ return None;
+ }
+ std::str::from_utf8(value).ok()?.parse().ok()
+ }
+
+ async fn execute_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ if request.image_optimizer.is_some() || request.stream_response {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("unsupported option on Axum raw proxy request"));
+ }
+
+ let logical_uri = request.request.uri().to_string();
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri)?;
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let uri = transport_uri.as_deref().unwrap_or(&logical_uri);
+ #[cfg(not(feature = "aps-runner-proxy-integration-test"))]
+ let uri = logical_uri.as_str();
+ let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes())
+ .change_context(PlatformError::HttpClient)?;
+ let mut builder = self.client.request(method, uri);
+ for (name, value) in request.request.headers() {
+ builder = builder.header(name.as_str(), value.as_bytes());
+ }
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ if transport_uri.is_some() {
+ builder = builder
+ .header(reqwest::header::HOST, "client.aps.amazon-adsystem.com")
+ .header("x-ts-aps-logical-url", logical_uri.as_str());
+ }
+ let (_, request_body) = request.request.into_parts();
+ let request_body = Self::buffer_body(request_body).await?;
+ if !request_body.is_empty() {
+ builder = builder.body(request_body);
+ }
+
+ tokio::time::timeout(policy.total_timeout, async move {
+ let mut response = tokio::time::timeout(policy.first_byte_timeout, builder.send())
+ .await
+ .map_err(|_| {
+ Report::new(PlatformError::HttpClient)
+ .attach("raw proxy first-byte deadline exceeded")
+ })?
+ .change_context(PlatformError::HttpClient)?;
+ let evidence = ProxyResponseEvidenceV1 {
+ status: response.status().as_u16(),
+ content_type: Self::raw_header_evidence(
+ response.headers(),
+ reqwest::header::CONTENT_TYPE,
+ ),
+ content_encoding: Self::raw_header_evidence(
+ response.headers(),
+ reqwest::header::CONTENT_ENCODING,
+ ),
+ content_length: Self::raw_header_evidence(
+ response.headers(),
+ reqwest::header::CONTENT_LENGTH,
+ ),
+ };
+ if Self::canonical_declared_length(&evidence.content_length)
+ .is_some_and(|length| length > policy.max_response_bytes)
+ {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy declared body exceeds configured cap"));
+ }
+
+ let mut body = Vec::new();
+ loop {
+ let chunk = tokio::time::timeout(policy.blocking_read_timeout, response.chunk())
+ .await
+ .map_err(|_| {
+ Report::new(PlatformError::HttpClient)
+ .attach("raw proxy blocking-read deadline exceeded")
+ })?
+ .change_context(PlatformError::HttpClient)?;
+ let Some(chunk) = chunk else { break };
+ let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| {
+ Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow")
+ })?;
+ if next_len > policy.max_response_bytes {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy body exceeds configured cap"));
+ }
+ body.extend_from_slice(&chunk);
+ }
+ Ok(RawProxyResponseV1 { evidence, body })
+ })
+ .await
+ .map_err(|_| {
+ Report::new(PlatformError::HttpClient).attach("raw proxy total deadline exceeded")
+ })?
+ }
}
impl Default for AxumPlatformHttpClient {
@@ -397,6 +554,14 @@ impl PlatformHttpClient for AxumPlatformHttpClient {
self.execute(request).await
}
+ async fn send_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ self.execute_raw_proxy_v1(request, policy).await
+ }
+
async fn send_async(
&self,
request: PlatformHttpRequest,
@@ -756,6 +921,223 @@ mod tests {
);
}
+ fn raw_proxy_request(url: &str) -> PlatformHttpRequest {
+ PlatformHttpRequest::new(
+ edgezero_core::http::request_builder()
+ .uri(url)
+ .header(header::ACCEPT_ENCODING, "identity")
+ .body(EdgeBody::empty())
+ .expect("should build raw proxy request"),
+ "test_backend",
+ )
+ }
+
+ fn raw_proxy_policy(timeout: Duration, max_response_bytes: usize) -> RawProxyPolicyV1 {
+ RawProxyPolicyV1 {
+ total_timeout: timeout,
+ first_byte_timeout: timeout,
+ blocking_read_timeout: timeout,
+ max_response_bytes,
+ }
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn raw_proxy_preserves_header_occurrences_and_exact_bytes() {
+ let url = serve_raw_response(
+ b"HTTP/1.1 200 OK\r\n\
+ Content-Type: application/javascript\r\n\
+ Content-Encoding: identity\r\n\
+ Content-Length: 2\r\n\
+ Set-Cookie: must-not-enter-core=1\r\n\
+ \r\n\
+ ok",
+ )
+ .await;
+
+ let response = AxumPlatformHttpClient::new()
+ .send_raw_proxy_v1(
+ raw_proxy_request(&url),
+ raw_proxy_policy(Duration::from_secs(1), 2),
+ )
+ .await
+ .expect("valid raw response should be collected");
+
+ assert_eq!(response.evidence.status, 200);
+ assert_eq!(
+ response.evidence.content_type,
+ ProxyHeaderEvidenceV1::one("application/javascript")
+ );
+ assert_eq!(
+ response.evidence.content_encoding,
+ ProxyHeaderEvidenceV1::one("identity")
+ );
+ assert_eq!(
+ response.evidence.content_length,
+ ProxyHeaderEvidenceV1::one("2")
+ );
+ assert_eq!(response.body, b"ok");
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn raw_proxy_preserves_duplicate_security_headers_for_core_rejection() {
+ let url = serve_raw_response(
+ b"HTTP/1.1 200 OK\r\n\
+ Content-Type: application/javascript\r\n\
+ Content-Type: text/javascript\r\n\
+ Content-Length: 2\r\n\
+ \r\n\
+ ok",
+ )
+ .await;
+
+ let response = AxumPlatformHttpClient::new()
+ .send_raw_proxy_v1(
+ raw_proxy_request(&url),
+ raw_proxy_policy(Duration::from_secs(1), 2),
+ )
+ .await
+ .expect("transport should preserve duplicate evidence");
+
+ assert_eq!(
+ response.evidence.content_type,
+ ProxyHeaderEvidenceV1::Occurrences(vec![
+ b"application/javascript".to_vec(),
+ b"text/javascript".to_vec(),
+ ])
+ );
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn raw_proxy_cancels_on_body_overflow_and_total_deadline() {
+ let overflow_url = serve_raw_response(
+ b"HTTP/1.1 200 OK\r\n\
+ Content-Type: application/javascript\r\n\
+ Transfer-Encoding: chunked\r\n\
+ \r\n\
+ 2\r\n\
+ ok\r\n\
+ 0\r\n\
+ \r\n",
+ )
+ .await;
+ let overflow = AxumPlatformHttpClient::new()
+ .send_raw_proxy_v1(
+ raw_proxy_request(&overflow_url),
+ raw_proxy_policy(Duration::from_secs(1), 1),
+ )
+ .await;
+ assert!(overflow.is_err(), "one byte over the cap must fail");
+
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("should bind deadline test server");
+ let addr = listener.local_addr().expect("should read local address");
+ tokio::spawn(async move {
+ let (mut stream, _) = listener.accept().await.expect("should accept request");
+ let mut request = [0; 1024];
+ let _ = stream
+ .read(&mut request)
+ .await
+ .expect("should read request");
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ let _ = stream
+ .write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok",
+ )
+ .await;
+ });
+ let deadline = AxumPlatformHttpClient::new()
+ .send_raw_proxy_v1(
+ raw_proxy_request(&format!("http://{addr}/")),
+ raw_proxy_policy(Duration::from_millis(20), 2),
+ )
+ .await;
+ assert!(deadline.is_err(), "total deadline must cover first byte");
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn raw_proxy_enforces_first_byte_and_blocking_read_deadlines() {
+ let first_byte_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("should bind first-byte deadline server");
+ let first_byte_addr = first_byte_listener
+ .local_addr()
+ .expect("should read first-byte server address");
+ tokio::spawn(async move {
+ let (mut stream, _) = first_byte_listener
+ .accept()
+ .await
+ .expect("should accept first-byte request");
+ let mut request = [0; 1024];
+ let _ = stream
+ .read(&mut request)
+ .await
+ .expect("should read first-byte request");
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ let _ = stream
+ .write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok",
+ )
+ .await;
+ });
+ let first_byte = AxumPlatformHttpClient::new()
+ .send_raw_proxy_v1(
+ raw_proxy_request(&format!("http://{first_byte_addr}/")),
+ RawProxyPolicyV1 {
+ total_timeout: Duration::from_secs(1),
+ first_byte_timeout: Duration::from_millis(20),
+ blocking_read_timeout: Duration::from_secs(1),
+ max_response_bytes: 2,
+ },
+ )
+ .await;
+ assert!(
+ first_byte.is_err(),
+ "response headers after the first-byte deadline must fail"
+ );
+
+ let body_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("should bind blocking-read deadline server");
+ let body_addr = body_listener
+ .local_addr()
+ .expect("should read blocking-read server address");
+ tokio::spawn(async move {
+ let (mut stream, _) = body_listener
+ .accept()
+ .await
+ .expect("should accept blocking-read request");
+ let mut request = [0; 1024];
+ let _ = stream
+ .read(&mut request)
+ .await
+ .expect("should read blocking-read request");
+ stream
+ .write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\no\r\n",
+ )
+ .await
+ .expect("should write first body chunk");
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ let _ = stream.write_all(b"1\r\nk\r\n0\r\n\r\n").await;
+ });
+ let blocking_read = AxumPlatformHttpClient::new()
+ .send_raw_proxy_v1(
+ raw_proxy_request(&format!("http://{body_addr}/")),
+ RawProxyPolicyV1 {
+ total_timeout: Duration::from_secs(1),
+ first_byte_timeout: Duration::from_secs(1),
+ blocking_read_timeout: Duration::from_millis(20),
+ max_response_bytes: 2,
+ },
+ )
+ .await;
+ assert!(
+ blocking_read.is_err(),
+ "a body read blocked past its deadline must fail"
+ );
+ }
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn select_attributes_failed_backend_name() {
// Bind and immediately drop a listener so the port is closed — the
diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs
index 03caa3d11..d7cfa40c4 100644
--- a/crates/trusted-server-adapter-axum/tests/routes.rs
+++ b/crates/trusted-server-adapter-axum/tests/routes.rs
@@ -18,14 +18,19 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] =
/// The settings baked into the binary contain placeholder secrets that
/// `get_settings()` rejects by design, which would turn every route into a
/// startup error page (and its route table into the fallback-only set).
-fn test_router() -> edgezero_core::router::RouterService {
- let settings = trusted_server_core::settings::Settings::from_toml(
+fn test_settings() -> trusted_server_core::settings::Settings {
+ trusted_server_core::settings::Settings::from_toml(
r#"
[[handlers]]
path = "^/_ts/admin"
username = "admin"
password = "admin-pass"
+ [[handlers]]
+ path = "^/integrations/aps"
+ username = "aps-user"
+ password = "aps-pass"
+
[publisher]
domain = "test-publisher.example.com"
cookie_domain = ".test-publisher.example.com"
@@ -34,14 +39,33 @@ fn test_router() -> edgezero_core::router::RouterService {
[ec]
passphrase = "test-secret-key-32-bytes-minimum"
+
+ [integrations.aps]
+ enabled = true
+ account_id = "route-test-aps-account"
+ allow_script_creatives = true
"#,
)
- .expect("should parse route test settings");
+ .expect("should parse route test settings")
+}
- TrustedServerApp::routes_with_settings(settings)
+fn test_router() -> edgezero_core::router::RouterService {
+ TrustedServerApp::routes_with_settings(test_settings())
.expect("should build router from test settings")
}
+async fn route_reserved(request: Request) -> axum::http::Response {
+ let request = edgezero_adapter_axum::request::into_core_request(request)
+ .await
+ .expect("should convert reserved APS request");
+ let response =
+ trusted_server_adapter_axum::app::dispatch_reserved_with_settings(test_settings(), request)
+ .await
+ .expect("should build APS dispatcher")
+ .expect("APS family should be reserved");
+ edgezero_adapter_axum::response::into_axum_response(response)
+}
+
fn make_service() -> EdgeZeroAxumService {
EdgeZeroAxumService::new(test_router())
}
@@ -77,16 +101,9 @@ fn all_explicit_routes_are_registered() {
("POST", "/admin/keys/rotate"),
("POST", "/admin/keys/deactivate"),
("POST", "/auction"),
- // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both
- // paths are spelled out as literals rather than referencing
- // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the
- // actual URL the tsjs client fetches — asserting a const against itself
- // would still pass if the const's value changed out from under the
- // client.
+ // Pin the canonical literal fetched by the hard-cutover client.
("GET", "/_ts/page-bids"),
("OPTIONS", "/_ts/page-bids"),
- ("GET", "/__ts/page-bids"),
- ("OPTIONS", "/__ts/page-bids"),
("GET", "/first-party/proxy"),
("GET", "/first-party/click"),
("GET", "/first-party/sign"),
@@ -98,6 +115,9 @@ fn all_explicit_routes_are_registered() {
for (method, path) in expected {
assert_route_registered(method, path);
}
+ for method in LEGACY_ADMIN_DENY_METHODS {
+ assert_route_registered(method, "/__ts/page-bids");
+ }
}
/// Verify the legacy non-`/_ts` admin aliases ARE registered — to the local
@@ -208,6 +228,133 @@ async fn tsjs_route_prefix_is_handled_not_5xx() {
);
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn tsjs_wrong_methods_are_local_no_store_404s() {
+ for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] {
+ let req = Request::builder()
+ .method(method)
+ .uri(format!(
+ "/static/tsjs=tsjs-unified.min.js?v={}",
+ "0".repeat(64)
+ ))
+ .body(AxumBody::empty())
+ .expect("should build wrong-method TSJS request");
+ let response = make_service()
+ .oneshot(req)
+ .await
+ .expect("should reject TSJS request locally");
+
+ assert_eq!(response.status().as_u16(), 404, "method {method}");
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("no-store"),
+ "method {method}"
+ );
+ assert!(
+ !response.headers().contains_key("location"),
+ "method {method}"
+ );
+ }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn aps_cutover_renderer_and_family_failures_are_local() {
+ let renderer = Request::builder()
+ .method("GET")
+ .uri("/integrations/aps/renderer/v1")
+ .header("authorization", "Bearer must-not-reach-publisher")
+ .body(AxumBody::empty())
+ .expect("should build APS renderer request");
+ let response = route_reserved(renderer).await;
+ assert_eq!(response.status().as_u16(), 200);
+ assert_eq!(
+ response
+ .headers()
+ .get("content-type")
+ .and_then(|value| value.to_str().ok()),
+ Some("text/html; charset=utf-8")
+ );
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("public, max-age=31536000, immutable")
+ );
+ assert!(response.headers().get("x-frame-options").is_none());
+ let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
+ .await
+ .expect("renderer body should be bounded");
+ let body = std::str::from_utf8(&body).expect("renderer should be UTF-8");
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(!body.contains("client.aps.amazon-adsystem.com"));
+
+ for (method, path, expected) in [
+ ("POST", "/integrations/aps/runner.js", 405),
+ ("TRACE", "/integrations/aps/renderer/v1", 405),
+ ("CONNECT", "/integrations/aps/renderer/v1", 405),
+ ("PROPFIND", "/integrations/aps/renderer/v1", 405),
+ ("GET", "/integrations/aps/renderer", 404),
+ ("GET", "/integrations/aps/renderer/v2", 404),
+ ("GET", "/integrations/aps/runner/v1.js", 404),
+ ("GET", "/integrations/aps", 404),
+ ] {
+ let request = Request::builder()
+ .method(method)
+ .uri(path)
+ .header("authorization", "Bearer must-not-reach-publisher")
+ .body(AxumBody::empty())
+ .expect("should build APS family request");
+ let response = route_reserved(request).await;
+ assert_eq!(response.status().as_u16(), expected, "{method} {path}");
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("no-store"),
+ "{method} {path}"
+ );
+ assert!(
+ response.headers().get("x-geo-info-available").is_none(),
+ "{method} {path} must not receive generic finalizer headers"
+ );
+ if expected == 405 {
+ assert_eq!(
+ response
+ .headers()
+ .get("allow")
+ .and_then(|v| v.to_str().ok()),
+ Some("GET")
+ );
+ assert_eq!(response.headers().len(), 2, "{method} {path}");
+ } else {
+ assert_eq!(response.headers().len(), 1, "{method} {path}");
+ }
+ let body = axum::body::to_bytes(response.into_body(), 1)
+ .await
+ .expect("local APS failure body should be empty");
+ assert!(body.is_empty(), "{method} {path}");
+ }
+
+ let protected_control = Request::builder()
+ .method("GET")
+ .uri("/integrations/apsx")
+ .body(AxumBody::empty())
+ .expect("should build protected non-APS boundary request");
+ let response = make_service()
+ .ready()
+ .await
+ .expect("should be ready")
+ .call(protected_control)
+ .await
+ .expect("should auth-gate non-APS boundary request");
+ assert_eq!(response.status().as_u16(), 401);
+}
+
// ---------------------------------------------------------------------------
// Middleware tests
// ---------------------------------------------------------------------------
diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml
index 097844012..e4e5e4ca7 100644
--- a/crates/trusted-server-adapter-cloudflare/Cargo.toml
+++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml
@@ -19,6 +19,7 @@ crate-type = ["cdylib", "rlib"]
default = []
# Keep for explicit `cargo check --features cloudflare --target wasm32-unknown-unknown`
cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"]
+aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"]
[dependencies]
async-trait = { workspace = true }
diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh
index dcabdee8e..cbd78c23a 100644
--- a/crates/trusted-server-adapter-cloudflare/build.sh
+++ b/crates/trusted-server-adapter-cloudflare/build.sh
@@ -33,4 +33,9 @@ if [ -z "$WORKER_VERSION" ]; then
echo "error: could not determine the worker crate version from Cargo.lock" >&2
exit 1
fi
-cargo install -q --force --version "=$WORKER_VERSION" worker-build && worker-build --release
+cargo install -q --force --version "=$WORKER_VERSION" worker-build
+if [ -n "${TS_WORKER_BUILD_FEATURES:-}" ]; then
+ worker-build --release . --features "$TS_WORKER_BUILD_FEATURES"
+else
+ worker-build --release
+fi
diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs
index 644676fc5..0990668e9 100644
--- a/crates/trusted-server-adapter-cloudflare/src/app.rs
+++ b/crates/trusted-server-adapter-cloudflare/src/app.rs
@@ -21,14 +21,14 @@ use trusted_server_core::proxy::{
handle_first_party_proxy_sign,
};
use trusted_server_core::publisher::{
- AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse,
- buffer_publisher_response_async, handle_page_bids, handle_publisher_request,
- handle_tsjs_dynamic, page_bids_preflight_denied,
+ AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async,
+ handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied,
};
use trusted_server_core::request_signing::{
handle_trusted_server_discovery, handle_verify_signature,
};
use trusted_server_core::settings::Settings;
+use trusted_server_core::trace_cookie::handle_trace_mode;
use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware};
use crate::platform::build_runtime_services;
@@ -117,6 +117,47 @@ fn build_state_with_settings(
}))
}
+async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option {
+ if !state.registry.has_reserved_path(req.uri().path()) {
+ return None;
+ }
+ let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default());
+ let services = build_runtime_services(&ctx);
+ Some(
+ state
+ .registry
+ .handle_reserved_proxy(&state.settings, &services, ctx.into_request())
+ .await
+ .expect("reserved path should have a hard-cutover handler")
+ .unwrap_or_else(|report| http_error(&report)),
+ )
+}
+
+/// Dispatch a reserved request using explicit settings.
+///
+/// # Errors
+///
+/// Returns an error when the adapter state cannot be built from `settings`.
+pub async fn dispatch_reserved_with_settings(
+ settings: Settings,
+ req: Request,
+) -> Result, Report> {
+ let state = build_state_with_settings(settings)?;
+ Ok(dispatch_reserved_for_state(&state, req).await)
+}
+
+/// Dispatch a reserved request using the configured adapter state.
+///
+/// # Errors
+///
+/// Returns an error when the configured adapter state cannot be built.
+pub async fn dispatch_reserved(
+ req: Request,
+) -> Result, Report> {
+ let state = build_state()?;
+ Ok(dispatch_reserved_for_state(&state, req).await)
+}
+
// ---------------------------------------------------------------------------
// Per-request RuntimeServices
// ---------------------------------------------------------------------------
@@ -361,7 +402,7 @@ fn build_router(state: &Arc) -> RouterService {
{
let state = Arc::clone(state);
- // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher.
+ // Shared fallback dispatch: routes to tsjs (GET/HEAD), integration proxy, or publisher.
async fn dispatch(
state: Arc,
ctx: RequestContext,
@@ -376,10 +417,7 @@ fn build_router(state: &Arc) -> RouterService {
}
let path = req.uri().path().to_owned();
let method = req.method().clone();
- // tsjs assets are served for GET only, matching the Axum/Fastly adapters.
- let allow_tsjs = method == Method::GET;
-
- let result = if allow_tsjs && path.starts_with("/static/tsjs=") {
+ let result = if path.starts_with("/static/tsjs=") {
handle_tsjs_dynamic(&req, &state.registry)
} else if state.registry.has_route(&method, &path) {
let mut ec_context = EcContext::default();
@@ -474,6 +512,15 @@ fn build_router(state: &Arc) -> RouterService {
.post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async {
Ok::(admin_key_management_not_supported())
})
+ // Render-trace toggle: arms/disarms the ts-trace cookie and
+ // redirects to `/`. Gated by [debug] trace_route_enabled (404 when
+ // off).
+ .get(
+ "/_ts/trace",
+ make_handler(Arc::clone(&state), |s, _services, req| async move {
+ handle_trace_mode(&s.settings, req.uri().query())
+ }),
+ )
.post(
"/auction",
make_handler(Arc::clone(&state), |s, services, req| async move {
@@ -534,15 +581,8 @@ fn build_router(state: &Arc) -> RouterService {
}),
);
- // SPA re-auction endpoint, registered on the canonical path and on the
- // deprecated `PAGE_BIDS_LEGACY_PATH` double-underscore alias. The alias
- // keeps tsjs bundles served before the `/_ts/page-bids` rename getting
- // ads on SPA navigations until they age out of browser caches.
- //
- // The OPTIONS preflight is denied on both so the GET handler's
- // `X-TSJS-Page-Bids` gate stays trustworthy — an alias that let the
- // preflight fall through to a permissive origin would reopen exactly
- // the cross-site hole the canonical path closes.
+ // SPA re-auction endpoint. OPTIONS is denied so the GET handler's
+ // `X-TSJS-Page-Bids` gate stays trustworthy.
let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move {
let ec_context = build_ec_context(&s.settings, &services, &req);
let auction = AuctionDispatch {
@@ -556,10 +596,8 @@ fn build_router(state: &Arc) -> RouterService {
make_handler(Arc::clone(&state), |_s, _services, _req| async move {
Ok(page_bids_preflight_denied())
});
- for path in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] {
- router = router.route(path, Method::GET, page_bids.clone());
- router = router.route(path, Method::OPTIONS, page_bids_preflight.clone());
- }
+ router = router.route(PAGE_BIDS_PATH, Method::GET, page_bids);
+ router = router.route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_preflight);
let legacy_admin_deny =
make_handler(Arc::clone(&state), |_s, _services, _req| async move {
@@ -573,6 +611,9 @@ fn build_router(state: &Arc) -> RouterService {
);
router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone());
}
+ for method in publisher_fallback_methods() {
+ router = router.route("/__ts/page-bids", method, legacy_admin_deny.clone());
+ }
for method in publisher_fallback_methods() {
router = router.route("/", method.clone(), fallback.clone());
diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs
index 2ce435b17..3ab7d3434 100644
--- a/crates/trusted-server-adapter-cloudflare/src/lib.rs
+++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs
@@ -15,6 +15,11 @@ pub mod platform;
#[cfg(target_arch = "wasm32")]
use worker::{Context, Env, Request, Response, Result, event};
+#[cfg(any(target_arch = "wasm32", test))]
+fn preserved_reserved_method(value: &str) -> Option {
+ edgezero_core::http::Method::from_bytes(value.as_bytes()).ok()
+}
+
#[cfg(target_arch = "wasm32")]
#[event(fetch)]
/// Dispatches an incoming Cloudflare Worker fetch event.
@@ -28,6 +33,31 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result {
app::set_cloudflare_config_json(config.to_string());
}
+ let is_reserved = req
+ .url()
+ .is_ok_and(|url| trusted_server_core::integrations::aps::is_aps_family_path(url.path()));
+ if is_reserved {
+ // workers-rs maps unknown methods to GET; the underlying Fetch request
+ // preserves the original method token, so capture it before conversion.
+ let method = preserved_reserved_method(&req.inner().method()).ok_or_else(|| {
+ worker::Error::RustError("reserved APS request method is invalid".to_string())
+ })?;
+ let mut request = edgezero_adapter_cloudflare::request::into_core_request(req, env, ctx)
+ .await
+ .map_err(|error| worker::Error::RustError(error.to_string()))?;
+ *request.method_mut() = method;
+ let response = app::dispatch_reserved(request)
+ .await
+ .map_err(|error| worker::Error::RustError(error.to_string()))?
+ .ok_or_else(|| {
+ worker::Error::RustError(
+ "reserved APS path has no hard-cutover handler".to_string(),
+ )
+ })?;
+ return edgezero_adapter_cloudflare::response::from_core_response(response)
+ .map_err(|error| worker::Error::RustError(error.to_string()));
+ }
+
match edgezero_adapter_cloudflare::run_app::(req, env, ctx).await {
Ok(resp) => Ok(resp),
Err(e) => {
@@ -36,3 +66,16 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::preserved_reserved_method;
+
+ #[test]
+ fn reserved_method_parser_preserves_extension_methods() {
+ let method = preserved_reserved_method("PROPFIND")
+ .expect("should preserve a syntactically valid extension method");
+
+ assert_eq!(method.as_str(), "PROPFIND");
+ }
+}
diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs
index fff0bfed1..4984b5999 100644
--- a/crates/trusted-server-adapter-cloudflare/src/platform.rs
+++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs
@@ -20,6 +20,7 @@ use error_stack::ResultExt as _;
#[cfg(target_arch = "wasm32")]
use trusted_server_core::platform::{
PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult,
+ ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1,
};
// ---------------------------------------------------------------------------
@@ -204,7 +205,13 @@ struct CloudflarePendingResponse {
/// fetch layer; the Workers runtime's global CPU budget (~30 s on paid plans)
/// is the only implicit deadline.
#[cfg(target_arch = "wasm32")]
-pub struct CloudflareHttpClient;
+pub struct CloudflareHttpClient {
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ aps_runner_proxy_test_fetcher: Option,
+}
+
+#[cfg(all(target_arch = "wasm32", feature = "aps-runner-proxy-integration-test"))]
+const APS_RUNNER_PROXY_TEST_SERVICE_BINDING: &str = "APS_RUNNER_PROXY_FIXTURE";
/// Maximum buffered upstream response body, mirroring the Fastly adapter's cap.
///
@@ -286,6 +293,27 @@ fn outbound_cache_mode(bypass_cache: bool) -> OutboundCacheMode {
#[cfg(target_arch = "wasm32")]
impl CloudflareHttpClient {
+ fn new(request_context: &edgezero_core::context::RequestContext) -> Self {
+ #[cfg(not(feature = "aps-runner-proxy-integration-test"))]
+ let _ = request_context;
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let aps_runner_proxy_test_fetcher =
+ edgezero_adapter_cloudflare::context::CloudflareRequestContext::get(
+ request_context.request(),
+ )
+ .and_then(|cloudflare_context| {
+ cloudflare_context
+ .env()
+ .service(APS_RUNNER_PROXY_TEST_SERVICE_BINDING)
+ .ok()
+ });
+
+ Self {
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ aps_runner_proxy_test_fetcher,
+ }
+ }
+
async fn execute(
&self,
request: PlatformHttpRequest,
@@ -444,6 +472,219 @@ impl CloudflareHttpClient {
Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name))
}
+
+ fn raw_header_evidence(headers: &worker::Headers, name: &str) -> ProxyHeaderEvidenceV1 {
+ match headers.get(name) {
+ Ok(Some(value)) => ProxyHeaderEvidenceV1::Combined(value.into_bytes()),
+ Ok(None) => ProxyHeaderEvidenceV1::absent(),
+ Err(_) => ProxyHeaderEvidenceV1::Unavailable,
+ }
+ }
+
+ fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option {
+ let value = match evidence {
+ ProxyHeaderEvidenceV1::Occurrences(values) => {
+ let [value] = values.as_slice() else {
+ return None;
+ };
+ value.as_slice()
+ }
+ ProxyHeaderEvidenceV1::Combined(value) => value.as_slice(),
+ ProxyHeaderEvidenceV1::Unavailable => return None,
+ };
+ if value.is_empty()
+ || !value.iter().all(u8::is_ascii_digit)
+ || (value.len() > 1 && value[0] == b'0')
+ {
+ return None;
+ }
+ std::str::from_utf8(value).ok()?.parse().ok()
+ }
+
+ async fn execute_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ use futures::{FutureExt as _, StreamExt as _, future::Either};
+ use worker::{
+ AbortController, CacheMode, Fetch, Headers, Method, Request, RequestInit,
+ RequestRedirect, ResponseBody,
+ };
+
+ if request.image_optimizer.is_some() || request.stream_response {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("unsupported option on Cloudflare raw proxy request"));
+ }
+
+ let cache_mode = outbound_cache_mode(request.bypass_cache);
+ let uri = request.request.uri().to_string();
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let use_test_service_binding = {
+ use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL;
+
+ uri == APS_RUNNER_UPSTREAM_URL
+ };
+ let method = Method::from(request.request.method().to_string());
+ let headers = Headers::new();
+ for (name, value) in request.request.headers() {
+ let value =
+ std::str::from_utf8(value.as_bytes()).change_context(PlatformError::HttpClient)?;
+ headers
+ .append(name.as_str(), value)
+ .change_context(PlatformError::HttpClient)?;
+ }
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ if use_test_service_binding {
+ headers
+ .set("x-ts-aps-logical-url", &uri)
+ .change_context(PlatformError::HttpClient)?;
+ }
+
+ let (_, body) = request.request.into_parts();
+ let body = match body {
+ edgezero_core::body::Body::Once(bytes) => bytes.to_vec(),
+ edgezero_core::body::Body::Stream(_) => {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("streaming request bodies are not supported on Cloudflare raw proxy"));
+ }
+ };
+ let mut init = RequestInit::new();
+ init.with_method(method)
+ .with_headers(headers)
+ .with_redirect(RequestRedirect::Manual);
+ if cache_mode == OutboundCacheMode::NoStore {
+ init.with_cache(CacheMode::NoStore);
+ }
+ if !body.is_empty() {
+ init.with_body(Some(js_sys::Uint8Array::from(body.as_slice()).into()));
+ }
+ let worker_request =
+ Request::new_with_init(&uri, &init).change_context(PlatformError::HttpClient)?;
+
+ let controller = AbortController::default();
+ let signal = controller.signal();
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let test_fetcher = if use_test_service_binding {
+ Some(self.aps_runner_proxy_test_fetcher.clone().ok_or_else(|| {
+ Report::new(PlatformError::HttpClient)
+ .attach("APS runner proxy integration service binding is unavailable")
+ })?)
+ } else {
+ None
+ };
+ let operation = async {
+ let fetch_operation = async {
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let response = if let Some(fetcher) = test_fetcher {
+ let mut bound_request: worker::HttpRequest = worker_request
+ .try_into()
+ .change_context(PlatformError::HttpClient)?;
+ bound_request.extensions_mut().insert(signal.clone());
+ let bound_response = fetcher
+ .fetch_request(bound_request)
+ .await
+ .change_context(PlatformError::HttpClient)?;
+ worker::Response::try_from(bound_response)
+ .change_context(PlatformError::HttpClient)?
+ } else {
+ let fetch = Fetch::Request(worker_request);
+ fetch
+ .send_with_signal(&signal)
+ .await
+ .change_context(PlatformError::HttpClient)?
+ };
+ #[cfg(not(feature = "aps-runner-proxy-integration-test"))]
+ let response = {
+ let fetch = Fetch::Request(worker_request);
+ fetch
+ .send_with_signal(&signal)
+ .await
+ .change_context(PlatformError::HttpClient)?
+ };
+ Ok::>(response)
+ }
+ .boxed_local();
+ let first_byte_deadline = worker::Delay::from(policy.first_byte_timeout).boxed_local();
+ let mut response =
+ match futures::future::select(fetch_operation, first_byte_deadline).await {
+ Either::Left((response, _)) => response?,
+ Either::Right(((), _)) => {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy first-byte deadline exceeded"));
+ }
+ };
+ let evidence = ProxyResponseEvidenceV1 {
+ status: response.status_code(),
+ content_type: Self::raw_header_evidence(response.headers(), "content-type"),
+ content_encoding: Self::raw_header_evidence(response.headers(), "content-encoding"),
+ content_length: Self::raw_header_evidence(response.headers(), "content-length"),
+ };
+ if Self::canonical_declared_length(&evidence.content_length)
+ .is_some_and(|length| length > policy.max_response_bytes)
+ {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy declared body exceeds configured cap"));
+ }
+
+ let mut body = match response.body().clone() {
+ ResponseBody::Empty => Vec::new(),
+ ResponseBody::Body(bytes) => bytes,
+ ResponseBody::Stream(_) => {
+ let mut stream = response
+ .stream()
+ .change_context(PlatformError::HttpClient)?;
+ let mut body = Vec::new();
+ loop {
+ let read = stream.next().boxed_local();
+ let read_deadline =
+ worker::Delay::from(policy.blocking_read_timeout).boxed_local();
+ let chunk = match futures::future::select(read, read_deadline).await {
+ Either::Left((chunk, _)) => chunk,
+ Either::Right(((), _)) => {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy blocking-read deadline exceeded"));
+ }
+ };
+ let Some(chunk) = chunk else { break };
+ let chunk = chunk.change_context(PlatformError::HttpClient)?;
+ let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| {
+ Report::new(PlatformError::HttpClient)
+ .attach("raw proxy body length overflow")
+ })?;
+ if next_len > policy.max_response_bytes {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy body exceeds configured cap"));
+ }
+ body.extend_from_slice(&chunk);
+ }
+ body
+ }
+ };
+ if body.len() > policy.max_response_bytes {
+ body.clear();
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy buffered body exceeds configured cap"));
+ }
+ Ok(RawProxyResponseV1 { evidence, body })
+ }
+ .boxed_local();
+ let deadline = worker::Delay::from(policy.total_timeout).boxed_local();
+
+ match futures::future::select(operation, deadline).await {
+ Either::Left((result, _)) => {
+ if result.is_err() {
+ controller.abort();
+ }
+ result
+ }
+ Either::Right(((), _)) => {
+ controller.abort();
+ Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy total deadline exceeded"))
+ }
+ }
+ }
}
#[cfg(target_arch = "wasm32")]
@@ -456,6 +697,14 @@ impl PlatformHttpClient for CloudflareHttpClient {
self.execute(request).await
}
+ async fn send_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ self.execute_raw_proxy_v1(request, policy).await
+ }
+
fn supports_concurrent_fanout(&self) -> bool {
// `send_async` executes each request eagerly, so multiple pending
// requests run sequentially. The auction orchestrator checks this
@@ -602,7 +851,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R
let client_ip = extract_client_ip(ctx);
#[cfg(target_arch = "wasm32")]
- let http_client: Arc = Arc::new(CloudflareHttpClient);
+ let http_client: Arc = Arc::new(CloudflareHttpClient::new(ctx));
#[cfg(not(target_arch = "wasm32"))]
let http_client: Arc = Arc::new(UnavailableHttpClient);
diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs
index 09e3ed324..668f86f95 100644
--- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs
+++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs
@@ -21,14 +21,19 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] =
/// The handler regex is the production-shaped `^/_ts/admin`, matching
/// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical
/// `/_ts/admin/keys/*` routes are auth-gated exactly as in production.
-fn test_router() -> RouterService {
- let settings = Settings::from_toml(
+fn test_settings() -> Settings {
+ Settings::from_toml(
r#"
[[handlers]]
path = "^/_ts/admin"
username = "admin"
password = "admin-pass"
+ [[handlers]]
+ path = "^/integrations/aps"
+ username = "aps-user"
+ password = "aps-pass"
+
[publisher]
domain = "test-publisher.example.com"
cookie_domain = ".test-publisher.example.com"
@@ -37,11 +42,18 @@ fn test_router() -> RouterService {
[ec]
passphrase = "test-secret-key-32-bytes-minimum"
+
+ [integrations.aps]
+ enabled = true
+ account_id = "route-test-aps-account"
+ allow_script_creatives = true
"#,
)
- .expect("should parse route test settings");
+ .expect("should parse route test settings")
+}
- TrustedServerApp::routes_with_settings(settings)
+fn test_router() -> RouterService {
+ TrustedServerApp::routes_with_settings(test_settings())
.expect("should build router from test settings")
}
@@ -58,6 +70,13 @@ async fn route(router: RouterService, req: Request) -> Response {
router.oneshot(req).await.expect("should route request")
}
+async fn route_reserved(req: Request) -> Response {
+ trusted_server_adapter_cloudflare::app::dispatch_reserved_with_settings(test_settings(), req)
+ .await
+ .expect("should build APS dispatcher")
+ .expect("APS family should be reserved")
+}
+
fn assert_route_registered(method: &str, path: &str) {
let routes = registered_routes();
assert!(
@@ -101,6 +120,74 @@ fn routes_build_without_panic() {
let _router = TrustedServerApp::routes();
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn aps_cutover_renderer_and_family_failures_are_local() {
+ let renderer = request_builder()
+ .method("GET")
+ .uri("/integrations/aps/renderer/v1")
+ .header("authorization", "Bearer must-not-reach-publisher")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build APS renderer request");
+ let response = route_reserved(renderer).await;
+ assert_eq!(response.status().as_u16(), 200);
+ assert_eq!(
+ response.headers()["content-type"],
+ "text/html; charset=utf-8"
+ );
+ assert_eq!(
+ response.headers()["cache-control"],
+ "public, max-age=31536000, immutable"
+ );
+ assert!(!response.headers().contains_key("x-frame-options"));
+ let body = response.into_body().into_bytes().unwrap_or_default();
+ let body = std::str::from_utf8(&body).expect("renderer should be UTF-8");
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(!body.contains("client.aps.amazon-adsystem.com"));
+
+ for (method, path, expected) in [
+ ("POST", "/integrations/aps/runner.js", 405),
+ ("TRACE", "/integrations/aps/renderer/v1", 405),
+ ("CONNECT", "/integrations/aps/renderer/v1", 405),
+ ("PROPFIND", "/integrations/aps/renderer/v1", 405),
+ ("GET", "/integrations/aps/renderer", 404),
+ ("GET", "/integrations/aps/renderer/v2", 404),
+ ("GET", "/integrations/aps/runner/v1.js", 404),
+ ("GET", "/integrations/aps", 404),
+ ] {
+ let request = request_builder()
+ .method(method)
+ .uri(path)
+ .header("authorization", "Bearer must-not-reach-publisher")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build APS family request");
+ let response = route_reserved(request).await;
+ assert_eq!(response.status().as_u16(), expected, "{method} {path}");
+ assert_eq!(response.headers()["cache-control"], "no-store");
+ assert!(!response.headers().contains_key("x-geo-info-available"));
+ if expected == 405 {
+ assert_eq!(response.headers()["allow"], "GET");
+ assert_eq!(response.headers().len(), 2, "{method} {path}");
+ } else {
+ assert_eq!(response.headers().len(), 1, "{method} {path}");
+ }
+ assert!(
+ response
+ .into_body()
+ .into_bytes()
+ .unwrap_or_default()
+ .is_empty()
+ );
+ }
+
+ let protected_control = request_builder()
+ .method("GET")
+ .uri("/integrations/apsx")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build protected non-APS boundary request");
+ let response = route(test_router(), protected_control).await;
+ assert_eq!(response.status().as_u16(), 401);
+}
+
// ---------------------------------------------------------------------------
// Middleware regression tests — verify FinalizeResponseMiddleware and
// AuthMiddleware are wired so they cannot be removed silently.
@@ -203,6 +290,35 @@ async fn tsjs_route_is_routed_not_5xx() {
assert!(status < 500, "tsjs route must not 5xx: got {status}");
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn tsjs_wrong_methods_are_local_no_store_404s() {
+ for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] {
+ let req = request_builder()
+ .method(method)
+ .uri(format!(
+ "/static/tsjs=tsjs-unified.min.js?v={}",
+ "0".repeat(64)
+ ))
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build wrong-method TSJS request");
+ let response = route(test_router(), req).await;
+
+ assert_eq!(response.status().as_u16(), 404, "method {method}");
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("no-store"),
+ "method {method}"
+ );
+ assert!(
+ !response.headers().contains_key("location"),
+ "method {method}"
+ );
+ }
+}
+
/// Verify that every expected explicit route is registered in the route table.
///
/// Uses [`RouterService::routes()`] for introspection rather than checking
@@ -216,16 +332,9 @@ fn all_explicit_routes_are_registered() {
("POST", "/_ts/admin/keys/rotate"),
("POST", "/_ts/admin/keys/deactivate"),
("POST", "/auction"),
- // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both
- // paths are spelled out as literals rather than referencing
- // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the
- // actual URL the tsjs client fetches — asserting a const against itself
- // would still pass if the const's value changed out from under the
- // client.
+ // Pin the canonical literal fetched by the hard-cutover client.
("GET", "/_ts/page-bids"),
("OPTIONS", "/_ts/page-bids"),
- ("GET", "/__ts/page-bids"),
- ("OPTIONS", "/__ts/page-bids"),
("GET", "/first-party/proxy"),
("GET", "/first-party/click"),
("GET", "/first-party/sign"),
@@ -237,6 +346,9 @@ fn all_explicit_routes_are_registered() {
for (method, path) in expected {
assert_route_registered(method, path);
}
+ for method in LEGACY_ADMIN_DENY_METHODS {
+ assert_route_registered(method, "/__ts/page-bids");
+ }
for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] {
for method in LEGACY_ADMIN_DENY_METHODS {
diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml
new file mode 100644
index 000000000..90ec710b0
--- /dev/null
+++ b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml
@@ -0,0 +1,16 @@
+name = "trusted-server-aps-runner-proxy-integration"
+main = "build/index.js"
+compatibility_date = "2024-09-23"
+compatibility_flags = ["nodejs_compat", "cache_option_enabled"]
+
+[[kv_namespaces]]
+binding = "TRUSTED_SERVER_KV"
+id = "aps-runner-proxy-local-kv"
+
+[[services]]
+binding = "APS_RUNNER_PROXY_FIXTURE"
+service = "aps-runner-proxy-fixture"
+
+[vars]
+# Replaced in a temporary copy by the integration-test controller.
+TRUSTED_SERVER_CONFIG = "{}"
diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml
index b6bc0f1a1..78477f714 100644
--- a/crates/trusted-server-adapter-fastly/Cargo.toml
+++ b/crates/trusted-server-adapter-fastly/Cargo.toml
@@ -10,6 +10,10 @@ version = { workspace = true }
[lints]
workspace = true
+[features]
+default = []
+aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"]
+
[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }
@@ -29,6 +33,7 @@ sha2 = { workspace = true }
trusted-server-core = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
+web-time = { workspace = true }
[dev-dependencies]
bytes = { workspace = true }
diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs
index d6090c983..721880172 100644
--- a/crates/trusted-server-adapter-fastly/src/app.rs
+++ b/crates/trusted-server-adapter-fastly/src/app.rs
@@ -26,6 +26,7 @@
//! | GET | `/_ts/api/v1/identify` | [`handle_identify`] |
//! | GET | `/_ts/set-tester` | [`handle_set_tester`] |
//! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] |
+//! | GET | `/_ts/trace` | [`handle_trace_mode`] |
//! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] |
//! | POST | `/auction` | [`handle_auction`] |
//! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] |
@@ -117,9 +118,8 @@ use trusted_server_core::proxy::{
handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign,
};
use trusted_server_core::publisher::{
- AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, handle_page_bids,
- handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied,
- publisher_response_into_streaming_response,
+ AuctionDispatch, PAGE_BIDS_PATH, handle_page_bids, handle_publisher_request,
+ handle_tsjs_dynamic, page_bids_preflight_denied, publisher_response_into_streaming_response,
};
use trusted_server_core::request_signing::{
handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery,
@@ -130,6 +130,7 @@ use trusted_server_core::settings_data::{
default_config_key, default_config_store_name, get_settings_from_config_store,
};
use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester};
+use trusted_server_core::trace_cookie::handle_trace_mode;
use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware};
use crate::platform::{
@@ -163,6 +164,25 @@ pub(crate) fn build_state() -> Result, Report>
build_state_from_settings(load_settings_from_config_store()?)
}
+pub(crate) async fn dispatch_reserved_for_state(
+ state: &Arc,
+ req: Request,
+) -> Option {
+ if !state.registry.has_reserved_path(req.uri().path()) {
+ return None;
+ }
+ let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default());
+ let services = build_per_request_services(state, &ctx);
+ Some(
+ state
+ .registry
+ .handle_reserved_proxy(&state.settings, &services, ctx.into_request())
+ .await
+ .expect("reserved path should have a hard-cutover handler")
+ .unwrap_or_else(|report| http_error(&report)),
+ )
+}
+
pub(crate) fn load_settings_from_config_store() -> Result> {
let store_name = default_config_store_name();
let config_key = default_config_key();
@@ -277,8 +297,8 @@ fn publisher_fallback_methods() -> [Method; 7] {
]
}
-fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool {
- *method == Method::GET && path.starts_with("/static/tsjs=")
+fn uses_dynamic_tsjs_fallback(_method: &Method, path: &str) -> bool {
+ path.starts_with("/static/tsjs=")
}
// ---------------------------------------------------------------------------
@@ -596,6 +616,7 @@ async fn run_named_route(
}
NamedRouteHandler::SetTester => handle_set_tester(&state.settings),
NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings),
+ NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()),
NamedRouteHandler::Auction => {
// The auction reads consent data, so the consent KV store must be
// available — fail closed with 503 when it is configured but
@@ -1008,6 +1029,7 @@ enum NamedRouteHandler {
Identify,
SetTester,
ClearTester,
+ TraceMode,
Auction,
PageBids,
FirstPartyProxy,
@@ -1089,6 +1111,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[
primary_methods: &[Method::GET],
handler: NamedRouteHandler::ClearTester,
},
+ NamedRoute {
+ path: "/_ts/trace",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::TraceMode,
+ },
NamedRoute {
path: "/auction",
primary_methods: &[Method::POST],
@@ -1101,15 +1128,12 @@ const NAMED_ROUTES: &[NamedRoute] = &[
primary_methods: &[Method::GET, Method::OPTIONS],
handler: NamedRouteHandler::PageBids,
},
- // Deprecated double-underscore alias. tsjs bundles served before the
- // `/_ts/page-bids` rename keep requesting this path from already-loaded
- // pages and browser caches; dropping it would strand SPA navigations
- // without ads until those bundles age out. See `PAGE_BIDS_LEGACY_PATH`;
- // removal is tracked by IABTechLab/trusted-server#970.
+ // A removed route must be denied here, before the publisher fallback, so
+ // its response is always a local unknown-route result rather than an alias.
NamedRoute {
- path: PAGE_BIDS_LEGACY_PATH,
- primary_methods: &[Method::GET, Method::OPTIONS],
- handler: NamedRouteHandler::PageBids,
+ path: "/__ts/page-bids",
+ primary_methods: LEGACY_ADMIN_DENY_METHODS,
+ handler: NamedRouteHandler::LegacyAdminDenied,
},
NamedRoute {
path: "/first-party/proxy",
@@ -1237,9 +1261,11 @@ impl Hooks for TrustedServerApp {
mod tests {
use std::sync::Arc;
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ use super::dispatch_reserved_for_state;
use super::{
- AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH,
- TrustedServerApp, build_state_from_settings, startup_error_router,
+ AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, TrustedServerApp,
+ build_state_from_settings, startup_error_router,
};
use bytes::Bytes;
use edgezero_core::body::Body;
@@ -1343,6 +1369,11 @@ mod tests {
username = "admin"
password = "admin-pass"
+ [[handlers]]
+ path = "^/integrations/aps"
+ username = "aps-user"
+ password = "aps-pass"
+
[publisher]
domain = "test-publisher.com"
cookie_domain = ".test-publisher.com"
@@ -1365,6 +1396,11 @@ mod tests {
server_url = "https://test-prebid.com/openrtb2/auction"
external_bundle_url = "https://assets.example/prebid/trusted-prebid.js"
+ [integrations.aps]
+ enabled = true
+ account_id = "route-test-aps-account"
+ allow_script_creatives = true
+
[auction]
enabled = true
providers = ["prebid"]
@@ -1379,6 +1415,100 @@ mod tests {
TrustedServerApp::routes_for_state(&state)
}
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ fn route_reserved(request: edgezero_core::http::Request) -> Response {
+ let state = build_state_from_settings(test_settings()).expect("should build test state");
+ block_on(dispatch_reserved_for_state(&state, request))
+ .expect("APS family should be reserved")
+ }
+
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ #[test]
+ fn aps_cutover_renderer_and_family_failures_are_local() {
+ let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v1"));
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.headers()[header::CONTENT_TYPE],
+ "text/html; charset=utf-8"
+ );
+ assert_eq!(
+ response.headers()[header::CACHE_CONTROL],
+ "public, max-age=31536000, immutable"
+ );
+ assert!(!response.headers().contains_key("x-frame-options"));
+ let body = response.into_body().into_bytes().unwrap_or_default();
+ let body = std::str::from_utf8(&body).expect("renderer should be UTF-8");
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(!body.contains("client.aps.amazon-adsystem.com"));
+
+ for (method, path, expected) in [
+ (
+ Method::POST,
+ "/integrations/aps/runner.js",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ Method::TRACE,
+ "/integrations/aps/renderer/v1",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ Method::CONNECT,
+ "/integrations/aps/renderer/v1",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"),
+ "/integrations/aps/renderer/v1",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ Method::GET,
+ "/integrations/aps/renderer",
+ StatusCode::NOT_FOUND,
+ ),
+ (
+ Method::GET,
+ "/integrations/aps/renderer/v2",
+ StatusCode::NOT_FOUND,
+ ),
+ (
+ Method::GET,
+ "/integrations/aps/runner/v1.js",
+ StatusCode::NOT_FOUND,
+ ),
+ (Method::GET, "/integrations/aps", StatusCode::NOT_FOUND),
+ ] {
+ let mut request = empty_request(method.clone(), path);
+ request.headers_mut().insert(
+ header::AUTHORIZATION,
+ "Bearer must-not-reach-publisher"
+ .parse()
+ .expect("should parse authorization header"),
+ );
+ let response = route_reserved(request);
+ assert_eq!(response.status(), expected, "{method} {path}");
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+ assert!(!response.headers().contains_key(HEADER_X_GEO_INFO_AVAILABLE));
+ if expected == StatusCode::METHOD_NOT_ALLOWED {
+ assert_eq!(response.headers()[header::ALLOW], "GET");
+ }
+ assert!(
+ response
+ .into_body()
+ .into_bytes()
+ .unwrap_or_default()
+ .is_empty()
+ );
+ }
+
+ let response = route(
+ &test_router(),
+ empty_request(Method::GET, "/integrations/apsx"),
+ );
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+ }
+
/// Builds a router whose `AppState` uses a registry containing the given
/// request filters (and no routes), so dispatch-level request-filter
/// behavior can be exercised without a real integration.
@@ -1512,18 +1642,18 @@ mod tests {
}
#[test]
- fn dynamic_tsjs_fallback_is_get_only() {
+ fn dynamic_tsjs_fallback_rejects_every_wrong_method_locally() {
assert!(
super::uses_dynamic_tsjs_fallback(&Method::GET, "/static/tsjs=tsjs-unified.js"),
"GET should use the dynamic tsjs shortcut"
);
assert!(
- !super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"),
- "HEAD should fall through to the publisher/integration fallback"
+ super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"),
+ "HEAD should use the local TSJS rejection path"
);
assert!(
- !super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"),
- "OPTIONS should fall through to the publisher/integration fallback"
+ super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"),
+ "OPTIONS should use the local TSJS rejection path"
);
}
@@ -1652,45 +1782,29 @@ mod tests {
}
#[test]
- fn page_bids_serves_canonical_path_and_deprecated_alias() {
- // The SPA re-auction endpoint lives at the canonical single-underscore
- // `/_ts/page-bids`, matching every other internal route. The deprecated
- // `/__ts/page-bids` alias must stay registered to the same handler with
- // the same methods until pre-rename tsjs bundles age out of browser
- // caches — dropping it would leave those clients without ads on SPA
- // navigations.
- //
- // The paths are literals, not `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`.
- // Looking a route up by the same const it was registered with is
- // tautological: it keeps passing if the const's value changes, which is
- // exactly the break that would silently desync the server from the tsjs
- // client's hardcoded fetch path. Pin the consts to their literals too so
- // a rename has to be deliberate.
+ fn page_bids_keeps_the_canonical_handler_and_denies_the_removed_alias_locally() {
+ // The hard cutover exposes only the canonical single-underscore page-bids
+ // handler. The removed path is an explicit local 404, never an alias or
+ // publisher-fallback route.
assert_eq!(
PAGE_BIDS_PATH, "/_ts/page-bids",
"canonical page-bids path must match the path tsjs fetches"
);
- assert_eq!(
- PAGE_BIDS_LEGACY_PATH, "/__ts/page-bids",
- "legacy alias must match the path pre-rename tsjs bundles fetch"
- );
-
- for path in ["/_ts/page-bids", "/__ts/page-bids"] {
- let route = NAMED_ROUTES
- .iter()
- .find(|route| route.path == path)
- .unwrap_or_else(|| panic!("{path} should be registered"));
-
- assert!(
- matches!(route.handler, NamedRouteHandler::PageBids),
- "{path} must map to the page-bids handler"
- );
- assert_eq!(
- route.primary_methods,
- &[Method::GET, Method::OPTIONS],
- "{path} must handle GET and OPTIONS directly, not fall through to the publisher"
- );
- }
+ let route = NAMED_ROUTES
+ .iter()
+ .find(|route| route.path == "/_ts/page-bids")
+ .expect("canonical page-bids path should be registered");
+ assert!(matches!(route.handler, NamedRouteHandler::PageBids));
+ assert_eq!(route.primary_methods, &[Method::GET, Method::OPTIONS]);
+ let removed = NAMED_ROUTES
+ .iter()
+ .find(|route| route.path == "/__ts/page-bids")
+ .expect("removed page-bids path should be denied locally");
+ assert!(matches!(
+ removed.handler,
+ NamedRouteHandler::LegacyAdminDenied
+ ));
+ assert_eq!(removed.primary_methods, super::LEGACY_ADMIN_DENY_METHODS);
}
#[test]
@@ -1815,6 +1929,55 @@ mod tests {
);
}
+ #[test]
+ fn dispatch_trace_route_is_disabled_by_default() {
+ let router = test_router();
+ let response = route(&router, empty_request(Method::GET, "/_ts/trace"));
+
+ assert_eq!(
+ response.status(),
+ StatusCode::NOT_FOUND,
+ "disabled trace route should return 404"
+ );
+ assert!(
+ response.headers().get(header::SET_COOKIE).is_none(),
+ "disabled trace route should not set a cookie"
+ );
+ }
+
+ #[test]
+ fn dispatch_trace_route_arms_cookie_and_redirects() {
+ let mut settings = test_settings();
+ settings.debug.trace_route_enabled = true;
+ let state = app_state_for_settings(settings);
+ let router = TrustedServerApp::routes_for_state(&state);
+ let response = route(&router, empty_request(Method::GET, "/_ts/trace"));
+
+ assert_eq!(
+ response.status(),
+ StatusCode::FOUND,
+ "enabled trace route should redirect to root"
+ );
+ assert_eq!(
+ response
+ .headers()
+ .get(header::LOCATION)
+ .and_then(|v| v.to_str().ok()),
+ Some("/"),
+ "trace route should redirect to /"
+ );
+ let set_cookie = response
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("should set trace cookie")
+ .to_str()
+ .expect("should render set-cookie as utf-8");
+ assert!(
+ set_cookie.starts_with("ts-trace=1;"),
+ "trace route should arm the ts-trace cookie"
+ );
+ }
+
#[test]
fn dispatch_set_tester_sets_cookie_on_configured_domain() {
let mut settings = test_settings();
diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs
index f2ff5d9e5..db55aa07b 100644
--- a/crates/trusted-server-adapter-fastly/src/backend.rs
+++ b/crates/trusted-server-adapter-fastly/src/backend.rs
@@ -328,10 +328,11 @@ impl<'a> BackendConfig<'a> {
/// Ensure a dynamic backend exists for this configuration and return its name.
///
- /// The name is a collision-resistant function of the complete backend spec
- /// (see `Self::compute_name`), so different specs — for example, different
- /// timeout values — always produce different backend registrations and a
- /// tight deadline cannot be silently widened by an earlier registration.
+ /// The backend name is derived from the scheme, host, port, certificate
+ /// setting, `first_byte_timeout`, and `between_bytes_timeout` to avoid
+ /// collisions. Different timeout values produce different backend
+ /// registrations so that a tight deadline cannot be silently widened by an
+ /// earlier registration.
///
/// # Errors
///
diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs
index 39d35b198..d5c2c2119 100644
--- a/crates/trusted-server-adapter-fastly/src/main.rs
+++ b/crates/trusted-server-adapter-fastly/src/main.rs
@@ -167,7 +167,20 @@ fn edgezero_main(mut req: FastlyRequest) {
core_req.extensions_mut().insert(config_store);
core_req.extensions_mut().insert(device_signals);
core_req.extensions_mut().insert(client_info);
- match futures::executor::block_on(app.router().oneshot(core_req)) {
+ let routed = if let Some(state) = app_state
+ .as_ref()
+ .filter(|state| state.registry.has_reserved_path(core_req.uri().path()))
+ {
+ Ok(
+ futures::executor::block_on(crate::app::dispatch_reserved_for_state(
+ state, core_req,
+ ))
+ .expect("reserved path should dispatch before RouterService"),
+ )
+ } else {
+ futures::executor::block_on(app.router().oneshot(core_req))
+ };
+ match routed {
Ok(response) => response,
Err(error) => edge_error_response(error),
}
@@ -186,7 +199,12 @@ fn edgezero_main(mut req: FastlyRequest) {
let asset_cache_policy = response.extensions_mut().remove::();
let request_filter_effects = response.extensions_mut().remove::();
- if !take_finalize_sentinel(&mut response) {
+ let should_finalize = response
+ .extensions()
+ .get::()
+ .is_none()
+ && !take_finalize_sentinel(&mut response);
+ if should_finalize {
if let Some(settings) = settings_snapshot.as_deref() {
apply_entry_point_finalize_headers(settings, &mut response, client_ip);
} else {
diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs
index 9e7920e1c..423a95a55 100644
--- a/crates/trusted-server-adapter-fastly/src/platform.rs
+++ b/crates/trusted-server-adapter-fastly/src/platform.rs
@@ -13,13 +13,18 @@ use fastly::geo::{Geo, geo_lookup};
use fastly::{ConfigStore, Request, SecretStore};
use crate::backend::BackendConfig;
+#[cfg(feature = "aps-runner-proxy-integration-test")]
+use trusted_server_core::integrations::aps::{
+ APS_RUNNER_BLOCKING_READ_TIMEOUT, APS_RUNNER_FIRST_BYTE_TIMEOUT, APS_RUNNER_UPSTREAM_URL,
+};
pub(crate) use trusted_server_core::platform::UnavailableKvStore;
use trusted_server_core::platform::{
ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError,
PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop,
PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams,
PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse,
- PlatformSecretStore, PlatformSelectResult, StoreId, StoreName,
+ PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1,
+ RawProxyPolicyV1, RawProxyResponseV1, StoreId, StoreName,
};
// ---------------------------------------------------------------------------
@@ -531,6 +536,31 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool)
}
}
+fn fastly_raw_header_evidence(response: &fastly::Response, name: &str) -> ProxyHeaderEvidenceV1 {
+ ProxyHeaderEvidenceV1::Occurrences(
+ response
+ .get_header_all(name)
+ .map(|value| value.as_bytes().to_vec())
+ .collect(),
+ )
+}
+
+fn canonical_fastly_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option {
+ let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else {
+ return None;
+ };
+ let [value] = values.as_slice() else {
+ return None;
+ };
+ if value.is_empty()
+ || !value.iter().all(u8::is_ascii_digit)
+ || (value.len() > 1 && value[0] == b'0')
+ {
+ return None;
+ }
+ std::str::from_utf8(value).ok()?.parse().ok()
+}
+
/// Fastly implementation of [`PlatformHttpClient`].
///
/// - [`send`](PlatformHttpClient::send) converts the platform request to a
@@ -545,6 +575,51 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool)
/// `fastly::http::request::select()`.
pub struct FastlyPlatformHttpClient;
+#[cfg(feature = "aps-runner-proxy-integration-test")]
+const APS_RUNNER_PROXY_TEST_BACKEND: &str = "aps_runner_proxy_fixture";
+
+#[cfg(feature = "aps-runner-proxy-integration-test")]
+fn aps_runner_proxy_test_backend(
+ policy: RawProxyPolicyV1,
+) -> Result> {
+ if policy.first_byte_timeout != APS_RUNNER_FIRST_BYTE_TIMEOUT
+ || policy.blocking_read_timeout != APS_RUNNER_BLOCKING_READ_TIMEOUT
+ {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("APS runner raw proxy policy does not match the static fixture timeouts"));
+ }
+ let fixture = fastly::Backend::from_name(APS_RUNNER_PROXY_TEST_BACKEND)
+ .change_context(PlatformError::HttpClient)?;
+ if !fixture.exists() || fixture.is_ssl() {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("APS runner fixture backend must exist as plain HTTP"));
+ }
+ let fixture_host = fixture.get_host();
+ let fixture_address = fixture_host.parse::().map_err(|_| {
+ Report::new(PlatformError::HttpClient)
+ .attach("APS runner fixture backend host must be a literal IP address")
+ })?;
+ if !fixture_address.is_loopback() {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("APS runner fixture backend host must be loopback"));
+ }
+ let logical_url =
+ url::Url::parse(APS_RUNNER_UPSTREAM_URL).change_context(PlatformError::HttpClient)?;
+ let logical_host = logical_url.host_str().ok_or_else(|| {
+ Report::new(PlatformError::HttpClient).attach("APS runner logical URL must contain a host")
+ })?;
+ if fixture
+ .get_host_override()
+ .as_ref()
+ .and_then(|host| host.to_str().ok())
+ != Some(logical_host)
+ {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("APS runner fixture backend must preserve the logical host"));
+ }
+ Ok(APS_RUNNER_PROXY_TEST_BACKEND.to_string())
+}
+
#[async_trait::async_trait(?Send)]
impl PlatformHttpClient for FastlyPlatformHttpClient {
fn supports_streaming_responses(&self) -> bool {
@@ -571,6 +646,88 @@ impl PlatformHttpClient for FastlyPlatformHttpClient {
fastly_response_to_platform(fastly_resp, backend_name, stream_response, request_is_head)
}
+ async fn send_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ if request.image_optimizer.is_some() || request.stream_response {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("unsupported option on Fastly raw proxy request"));
+ }
+
+ let started = web_time::Instant::now();
+ if policy.first_byte_timeout > policy.total_timeout {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy first-byte timeout exceeds total deadline"));
+ }
+ let backend_name = request.backend_name;
+ let mut fastly_request = edge_request_to_fastly(request.request)?;
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let backend_name = {
+ if fastly_request.get_url_str() == APS_RUNNER_UPSTREAM_URL {
+ fastly_request.set_header("x-ts-aps-logical-url", APS_RUNNER_UPSTREAM_URL);
+ aps_runner_proxy_test_backend(policy)?
+ } else {
+ backend_name
+ }
+ };
+ apply_fastly_cache_bypass(&mut fastly_request, request.bypass_cache);
+ let pending = fastly_request
+ .send_async(&backend_name)
+ .change_context(PlatformError::HttpClient)?;
+ // The backend carries the requested first-byte timeout. Waiting in the
+ // SDK lets the host suspend the guest instead of guest-side polling.
+ let mut response = pending.wait().change_context(PlatformError::HttpClient)?;
+ if started.elapsed() >= policy.total_timeout {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy total deadline exceeded before response headers"));
+ }
+
+ let evidence = ProxyResponseEvidenceV1 {
+ status: response.get_status().as_u16(),
+ content_type: fastly_raw_header_evidence(&response, "content-type"),
+ content_encoding: fastly_raw_header_evidence(&response, "content-encoding"),
+ content_length: fastly_raw_header_evidence(&response, "content-length"),
+ };
+ if canonical_fastly_declared_length(&evidence.content_length)
+ .is_some_and(|length| length > policy.max_response_bytes)
+ {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy declared body exceeds configured cap"));
+ }
+
+ let mut reader = response.take_body();
+ let mut body = Vec::new();
+ let mut chunk = [0_u8; 64 * 1024];
+ loop {
+ if started.elapsed() >= policy.total_timeout {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy total deadline exceeded before blocking body read"));
+ }
+ let read = reader
+ .read(&mut chunk)
+ .change_context(PlatformError::HttpClient)?;
+ if started.elapsed() >= policy.total_timeout {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy total deadline exceeded while reading body"));
+ }
+ if read == 0 {
+ break;
+ }
+ let next_len = body.len().checked_add(read).ok_or_else(|| {
+ Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow")
+ })?;
+ if next_len > policy.max_response_bytes {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy body exceeds configured cap"));
+ }
+ body.extend_from_slice(&chunk[..read]);
+ }
+
+ Ok(RawProxyResponseV1 { evidence, body })
+ }
+
async fn send_async(
&self,
request: PlatformHttpRequest,
@@ -760,6 +917,33 @@ mod tests {
);
}
+ #[test]
+ fn raw_proxy_waits_with_the_sdk_without_sleep_polling() {
+ let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/platform.rs"));
+ let raw_proxy = source
+ .split("async fn send_raw_proxy_v1(")
+ .nth(1)
+ .and_then(|source| source.split("fn supports_concurrent_fanout(").next())
+ .expect("should locate the Fastly raw proxy implementation");
+
+ assert!(
+ raw_proxy.contains("pending.wait()"),
+ "raw proxy should block in the Fastly SDK instead of guest-side polling"
+ );
+ assert!(!raw_proxy.contains("pending.poll()"));
+ assert!(!raw_proxy.contains("std::thread::sleep"));
+ assert!(
+ raw_proxy.contains("policy.total_timeout"),
+ "raw proxy should preserve the complete policy-owned total timeout"
+ );
+ assert!(
+ !raw_proxy.contains("call_start_deadline")
+ && !raw_proxy.contains("reduced deadline")
+ && !raw_proxy.contains("SAFETY_MARGIN"),
+ "raw proxy must not reserve time outside the exact transport window"
+ );
+ }
+
// --- FastlyPlatformBackend::predict_name --------------------------------
#[test]
diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml
index 77c4139bc..43ba8741f 100644
--- a/crates/trusted-server-adapter-spin/Cargo.toml
+++ b/crates/trusted-server-adapter-spin/Cargo.toml
@@ -18,6 +18,7 @@ crate-type = ["cdylib", "rlib"]
[features]
default = []
spin = ["edgezero-adapter-spin/spin"]
+aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"]
[dependencies]
anyhow = { workspace = true }
diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs
index 960bafc41..2dfd634ca 100644
--- a/crates/trusted-server-adapter-spin/src/app.rs
+++ b/crates/trusted-server-adapter-spin/src/app.rs
@@ -10,6 +10,8 @@ use edgezero_core::router::RouterService;
use error_stack::Report;
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
+#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))]
+use trusted_server_core::config_payload::settings_from_config_blob;
use trusted_server_core::ec::EcContext;
use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError};
use trusted_server_core::http_util::sanitize_forwarded_headers;
@@ -20,14 +22,14 @@ use trusted_server_core::proxy::{
handle_first_party_proxy_sign,
};
use trusted_server_core::publisher::{
- AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse,
- buffer_publisher_response_async, handle_page_bids, handle_publisher_request,
- handle_tsjs_dynamic, page_bids_preflight_denied,
+ AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async,
+ handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied,
};
use trusted_server_core::request_signing::{
handle_trusted_server_discovery, handle_verify_signature,
};
use trusted_server_core::settings::Settings;
+use trusted_server_core::trace_cookie::handle_trace_mode;
use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware};
use crate::platform::build_runtime_services;
@@ -49,11 +51,26 @@ pub struct AppState {
///
/// Returns an error when settings, the auction orchestrator, or the integration
/// registry fail to initialise.
+#[cfg(not(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32")))]
fn build_state() -> Result, Report> {
let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?;
build_state_with_settings(settings)
}
+#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))]
+fn build_state() -> Result, Report> {
+ let envelope =
+ futures::executor::block_on(spin_sdk::variables::get("v_trusted_x5fserver_x5fconfig"))
+ .map_err(|error| {
+ Report::new(TrustedServerError::Configuration {
+ message: "failed to read the Spin APS proxy test app config".to_string(),
+ })
+ .attach(error.to_string())
+ })?;
+ let settings = settings_from_config_blob(&envelope)?;
+ build_state_with_settings(settings)
+}
+
/// Build the application state from explicit settings.
///
/// # Errors
@@ -73,6 +90,49 @@ fn build_state_with_settings(
}))
}
+async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option {
+ if !state.registry.has_reserved_path(req.uri().path()) {
+ return None;
+ }
+ let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default());
+ let services = build_runtime_services(&ctx);
+ Some(
+ state
+ .registry
+ .handle_reserved_proxy(&state.settings, &services, ctx.into_request())
+ .await
+ .expect("reserved path should have a hard-cutover handler")
+ .unwrap_or_else(|report| http_error(&report)),
+ )
+}
+
+/// Dispatch a reserved APS request using explicit settings.
+///
+/// # Errors
+///
+/// Returns an error when the application state cannot be
+/// initialized from `settings`.
+pub async fn dispatch_reserved_with_settings(
+ settings: Settings,
+ req: Request,
+) -> Result, Report> {
+ let state = build_state_with_settings(settings)?;
+ Ok(dispatch_reserved_for_state(&state, req).await)
+}
+
+/// Dispatch a reserved APS request using startup settings.
+///
+/// # Errors
+///
+/// Returns an error when startup settings or the application
+/// state cannot be initialized.
+pub async fn dispatch_reserved(
+ req: Request,
+) -> Result, Report> {
+ let state = build_state()?;
+ Ok(dispatch_reserved_for_state(&state, req).await)
+}
+
// ---------------------------------------------------------------------------
// Publisher response helper
// ---------------------------------------------------------------------------
@@ -142,7 +202,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];
-fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] {
+fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] {
[
("/.well-known/trusted-server.json", &[Method::GET]),
("/verify-signature", &[Method::POST]),
@@ -150,9 +210,10 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] {
("/_ts/admin/keys/deactivate", &[Method::POST]),
("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS),
("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS),
+ ("/_ts/trace", &[Method::GET]),
("/auction", &[Method::POST]),
(PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]),
- (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]),
+ ("/__ts/page-bids", LEGACY_ADMIN_DENY_METHODS),
("/first-party/proxy", &[Method::GET]),
("/first-party/click", &[Method::GET]),
("/first-party/sign", &[Method::GET, Method::POST]),
@@ -551,6 +612,21 @@ fn build_router(state: &Arc) -> RouterService {
}
};
+ // GET /_ts/trace — render-trace toggle: arms/disarms the ts-trace
+ // cookie and redirects to `/`. Gated by [debug] trace_route_enabled
+ // (404 when off).
+ let s = Arc::clone(&state);
+ let trace_mode_handler = move |ctx: RequestContext| {
+ let s = Arc::clone(&s);
+ async move {
+ let req = ctx.into_request();
+ Ok::(
+ handle_trace_mode(&s.settings, req.uri().query())
+ .unwrap_or_else(|e| http_error(&e)),
+ )
+ }
+ };
+
// GET /_ts/page-bids — SPA re-auction endpoint.
let s = Arc::clone(&state);
let page_bids_handler = move |ctx: RequestContext| {
@@ -662,9 +738,7 @@ fn build_router(state: &Arc) -> RouterService {
let path = req.uri().path().to_owned();
let method = req.method().clone();
- // Dynamic tsjs serving is GET-only; other methods fall through to the
- // integration/publisher fallback.
- let result = if method == Method::GET && path.starts_with("/static/tsjs=") {
+ let result = if path.starts_with("/static/tsjs=") {
handle_tsjs_dynamic(&req, &state.registry)
} else if state.registry.has_route(&method, &path) {
let mut ec_context = EcContext::default();
@@ -758,19 +832,10 @@ fn build_router(state: &Arc) -> RouterService {
// credentials and key-management payloads to the origin.
.post("/_ts/admin/keys/rotate", admin_not_supported_handler)
.post("/_ts/admin/keys/deactivate", admin_not_supported_handler)
+ .get("/_ts/trace", trace_mode_handler)
.post("/auction", auction_handler)
- .get(PAGE_BIDS_PATH, page_bids_handler.clone())
+ .get(PAGE_BIDS_PATH, page_bids_handler)
.route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler)
- // Deprecated double-underscore alias, kept so tsjs bundles served
- // before the `/_ts/page-bids` rename keep getting ads on SPA
- // navigations until they age out of browser caches. See
- // `PAGE_BIDS_LEGACY_PATH`.
- .get(PAGE_BIDS_LEGACY_PATH, page_bids_handler)
- .route(
- PAGE_BIDS_LEGACY_PATH,
- Method::OPTIONS,
- page_bids_options_handler,
- )
.get("/first-party/proxy", fp_proxy_handler)
.get("/first-party/click", fp_click_handler)
.get("/first-party/sign", fp_sign_handler)
@@ -781,6 +846,7 @@ fn build_router(state: &Arc) -> RouterService {
for method in LEGACY_ADMIN_DENY_METHODS {
builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny);
builder = builder.route("/admin/keys/deactivate", method.clone(), legacy_admin_deny);
+ builder = builder.route("/__ts/page-bids", method.clone(), legacy_admin_deny);
}
// Mirror the Fastly/Axum publisher fallback: every supported method that is
diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs
index f47877ff2..5a6b20bc1 100644
--- a/crates/trusted-server-adapter-spin/src/lib.rs
+++ b/crates/trusted-server-adapter-spin/src/lib.rs
@@ -13,5 +13,15 @@ use spin_sdk::http_service;
#[http_service]
// FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice.
async fn handle(req: Request) -> anyhow::Result {
+ if trusted_server_core::integrations::aps::is_aps_family_path(req.uri().path()) {
+ let request = edgezero_adapter_spin::request::into_core_request(req).await?;
+ let response = app::dispatch_reserved(request)
+ .await
+ .map_err(|error| anyhow::anyhow!("{error:?}"))?
+ .expect("reserved APS path should dispatch before RouterService");
+ return edgezero_adapter_spin::response::from_core_response(response)
+ .await
+ .map_err(Into::into);
+ }
edgezero_adapter_spin::run_app::(req).await
}
diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs
index 492f1a518..e5b5f2daf 100644
--- a/crates/trusted-server-adapter-spin/src/platform.rs
+++ b/crates/trusted-server-adapter-spin/src/platform.rs
@@ -25,7 +25,8 @@ use std::io::Read as _;
use trusted_server_core::platform::PlatformHttpRequest;
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
use trusted_server_core::platform::{
- PlatformPendingRequest, PlatformResponse, PlatformSelectResult,
+ PlatformPendingRequest, PlatformResponse, PlatformSelectResult, ProxyHeaderEvidenceV1,
+ ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1,
};
// 8 MiB ceiling: conservative for ad-server responses while leaving headroom in
@@ -472,8 +473,56 @@ struct SpinPendingResponse {
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
pub struct SpinPlatformHttpClient;
+#[cfg(all(
+ feature = "aps-runner-proxy-integration-test",
+ any(test, all(feature = "spin", target_arch = "wasm32"))
+))]
+fn aps_runner_proxy_transport_uri(
+ logical_uri: &str,
+ endpoint: &str,
+) -> Result, Report> {
+ use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL;
+
+ if logical_uri != APS_RUNNER_UPSTREAM_URL {
+ return Ok(None);
+ }
+ let parsed: edgezero_core::http::Uri = endpoint.parse().map_err(|_| {
+ Report::new(PlatformError::HttpClient)
+ .attach("invalid APS runner proxy integration fixture endpoint")
+ })?;
+ if parsed.scheme_str() != Some("http")
+ || !matches!(parsed.host(), Some("127.0.0.1" | "::1"))
+ || parsed.port_u16().is_none()
+ || parsed.path().is_empty()
+ || parsed.query().is_some()
+ {
+ return Err(Report::new(PlatformError::HttpClient).attach(
+ "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL",
+ ));
+ }
+ Ok(Some(endpoint.to_owned()))
+}
+
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
impl SpinPlatformHttpClient {
+ #[cfg(all(
+ feature = "aps-runner-proxy-integration-test",
+ feature = "spin",
+ target_arch = "wasm32"
+ ))]
+ async fn aps_runner_proxy_test_transport_uri(
+ logical_uri: &str,
+ ) -> Result, Report> {
+ let endpoint = spin_sdk::variables::get("aps_runner_proxy_test_endpoint")
+ .await
+ .map_err(|_| {
+ Report::new(PlatformError::HttpClient).attach(
+ "APS runner proxy integration artifact requires its loopback fixture endpoint",
+ )
+ })?;
+ aps_runner_proxy_transport_uri(logical_uri, &endpoint)
+ }
+
async fn execute(
&self,
request: PlatformHttpRequest,
@@ -559,6 +608,173 @@ impl SpinPlatformHttpClient {
Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name))
}
+
+ fn raw_header_evidence(
+ headers: &spin_sdk::wasip3::http::types::Headers,
+ name: &str,
+ ) -> ProxyHeaderEvidenceV1 {
+ ProxyHeaderEvidenceV1::Occurrences(headers.get(name))
+ }
+
+ fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option {
+ let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else {
+ return None;
+ };
+ let [value] = values.as_slice() else {
+ return None;
+ };
+ if value.is_empty()
+ || !value.iter().all(u8::is_ascii_digit)
+ || (value.len() > 1 && value[0] == b'0')
+ {
+ return None;
+ }
+ std::str::from_utf8(value).ok()?.parse().ok()
+ }
+
+ async fn execute_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ use futures::{FutureExt as _, future::Either};
+ use spin_sdk::http::IntoRequest as _;
+ use spin_sdk::wasip3::http::types::RequestOptions;
+ use spin_sdk::wasip3::http_compat::{IncomingResponseBody, RequestOptionsExtension};
+
+ reject_unsupported_request_contracts(&request)?;
+ let method = request.request.method().clone();
+ let logical_uri = request.request.uri().to_string();
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri).await?;
+ let mut builder = spin_sdk::http::Request::builder()
+ .method(into_spin_method(&method))
+ .uri(&logical_uri);
+ for (name, value) in request.request.headers() {
+ if is_wasi_forbidden_outbound_header(name.as_str()) {
+ continue;
+ }
+ builder = builder.header(name.as_str(), value.as_bytes());
+ }
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ if transport_uri.is_some() {
+ builder = builder.header("x-ts-aps-logical-url", logical_uri);
+ }
+
+ let (_, request_body) = request.request.into_parts();
+ let request_body = match request_body {
+ edgezero_core::body::Body::Once(bytes) => bytes.to_vec(),
+ edgezero_core::body::Body::Stream(_) => {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("streaming request bodies are not supported on Spin raw proxy"));
+ }
+ };
+ let mut spin_request = builder
+ .body(spin_sdk::http::FullBody::new(Bytes::from(request_body)))
+ .map_err(|error| {
+ Report::new(PlatformError::HttpClient)
+ .attach(format!("failed to build Spin raw proxy request: {error}"))
+ })?;
+
+ // Spin/Wasmtime owns the wire `Host` header and forbids guests from
+ // setting it. Keep the fixed APS URL through the core→adapter contract,
+ // then apply the loopback-only integration target at the final lowering
+ // boundary. Production builds have no transport override constructor.
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ if let Some(transport_uri) = transport_uri {
+ *spin_request.uri_mut() = transport_uri.parse().map_err(|_| {
+ Report::new(PlatformError::HttpClient)
+ .attach("failed to lower APS loopback transport URI")
+ })?;
+ }
+
+ let timeout_nanos = policy.total_timeout.as_nanos().try_into().map_err(|_| {
+ Report::new(PlatformError::HttpClient)
+ .attach("raw proxy timeout exceeds WASI HTTP duration range")
+ })?;
+ let options = RequestOptions::new();
+ options
+ .set_connect_timeout(Some(timeout_nanos))
+ .map_err(|_| {
+ Report::new(PlatformError::Unsupported)
+ .attach("Spin raw proxy connect timeout is unavailable")
+ })?;
+ options
+ .set_first_byte_timeout(Some(timeout_nanos))
+ .map_err(|_| {
+ Report::new(PlatformError::Unsupported)
+ .attach("Spin raw proxy first-byte timeout is unavailable")
+ })?;
+ options
+ .set_between_bytes_timeout(Some(timeout_nanos))
+ .map_err(|_| {
+ Report::new(PlatformError::Unsupported)
+ .attach("Spin raw proxy between-bytes timeout is unavailable")
+ })?;
+ spin_request
+ .extensions_mut()
+ .insert(RequestOptionsExtension(options));
+ let wasi_request = spin_request.into_request().map_err(|error| {
+ Report::new(PlatformError::HttpClient)
+ .attach(format!("failed to lower Spin raw proxy request: {error}"))
+ })?;
+
+ let operation = async move {
+ let response = spin_sdk::wasip3::http::client::send(wasi_request)
+ .await
+ .map_err(|error| {
+ Report::new(PlatformError::HttpClient)
+ .attach(format!("Spin raw proxy request failed: {error}"))
+ })?;
+ let status = response.get_status_code();
+ let headers = response.get_headers();
+ let evidence = ProxyResponseEvidenceV1 {
+ status,
+ content_type: Self::raw_header_evidence(&headers, "content-type"),
+ content_encoding: Self::raw_header_evidence(&headers, "content-encoding"),
+ content_length: Self::raw_header_evidence(&headers, "content-length"),
+ };
+ if Self::canonical_declared_length(&evidence.content_length)
+ .is_some_and(|length| length > policy.max_response_bytes)
+ {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy declared body exceeds configured cap"));
+ }
+
+ let mut incoming = IncomingResponseBody::new(response).map_err(|error| {
+ Report::new(PlatformError::HttpClient)
+ .attach(format!("failed to open Spin raw proxy body: {error}"))
+ })?;
+ let mut body = Vec::new();
+ while let Some(frame) = incoming.frame().await {
+ let frame = frame.map_err(|error| {
+ Report::new(PlatformError::HttpClient)
+ .attach(format!("failed to read Spin raw proxy body: {error}"))
+ })?;
+ let Ok(data) = frame.into_data() else {
+ continue;
+ };
+ let next_len = body.len().checked_add(data.len()).ok_or_else(|| {
+ Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow")
+ })?;
+ if next_len > policy.max_response_bytes {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy body exceeds configured cap"));
+ }
+ body.extend_from_slice(&data);
+ }
+ Ok(RawProxyResponseV1 { evidence, body })
+ }
+ .boxed_local();
+ let deadline = spin_sdk::time::sleep(policy.total_timeout).boxed_local();
+ match futures::future::select(operation, deadline).await {
+ Either::Left((result, _)) => result,
+ Either::Right(((), _)) => {
+ Err(Report::new(PlatformError::HttpClient)
+ .attach("raw proxy total deadline exceeded"))
+ }
+ }
+ }
}
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
@@ -578,6 +794,14 @@ impl PlatformHttpClient for SpinPlatformHttpClient {
self.execute(request).await
}
+ async fn send_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ self.execute_raw_proxy_v1(request, policy).await
+ }
+
async fn send_async(
&self,
request: PlatformHttpRequest,
@@ -801,6 +1025,22 @@ mod tests {
use flate2::write::GzEncoder;
use std::io::Write as _;
+ #[cfg(feature = "aps-runner-proxy-integration-test")]
+ #[test]
+ fn aps_test_transport_mapping_preserves_logical_authority_until_lowering() {
+ use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL;
+
+ let endpoint = "http://127.0.0.1:49152/prebid-creative.js";
+ let transport = aps_runner_proxy_transport_uri(APS_RUNNER_UPSTREAM_URL, endpoint)
+ .expect("loopback integration endpoint should be accepted")
+ .expect("fixed APS URL should select the integration transport");
+ assert_eq!(transport.to_string(), endpoint);
+ let logical: edgezero_core::http::Uri = APS_RUNNER_UPSTREAM_URL
+ .parse()
+ .expect("fixed APS URL should parse");
+ assert_eq!(logical.host(), Some("client.aps.amazon-adsystem.com"));
+ }
+
fn make_ctx_without_spin_context() -> RequestContext {
let req = request_builder()
.method("GET")
diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs
index 2f7b1037e..7fb15c135 100644
--- a/crates/trusted-server-adapter-spin/tests/routes.rs
+++ b/crates/trusted-server-adapter-spin/tests/routes.rs
@@ -20,14 +20,19 @@ use trusted_server_core::settings::Settings;
/// The handler regex is the production-shaped `^/_ts/admin`, matching
/// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical
/// `/_ts/admin/keys/*` routes are auth-gated exactly as in production.
-fn test_router() -> RouterService {
- let settings = Settings::from_toml(
+fn test_settings() -> Settings {
+ Settings::from_toml(
r#"
[[handlers]]
path = "^/_ts/admin"
username = "admin"
password = "admin-pass"
+ [[handlers]]
+ path = "^/integrations/aps"
+ username = "aps-user"
+ password = "aps-pass"
+
[publisher]
domain = "test-publisher.example.com"
cookie_domain = ".test-publisher.example.com"
@@ -36,11 +41,18 @@ fn test_router() -> RouterService {
[ec]
passphrase = "test-secret-key-32-bytes-minimum"
+
+ [integrations.aps]
+ enabled = true
+ account_id = "route-test-aps-account"
+ allow_script_creatives = true
"#,
)
- .expect("should parse route test settings");
+ .expect("should parse route test settings")
+}
- TrustedServerApp::routes_with_settings(settings)
+fn test_router() -> RouterService {
+ TrustedServerApp::routes_with_settings(test_settings())
.expect("should build router from test settings")
}
@@ -48,6 +60,13 @@ async fn route(router: RouterService, req: Request) -> Response {
router.oneshot(req).await.expect("should route request")
}
+async fn route_reserved(req: Request) -> Response {
+ trusted_server_adapter_spin::app::dispatch_reserved_with_settings(test_settings(), req)
+ .await
+ .expect("should build APS dispatcher")
+ .expect("APS family should be reserved")
+}
+
#[test]
fn routes_build_without_panic() {
// build_state() may fail (no real settings in CI) — startup_error_router
@@ -55,6 +74,74 @@ fn routes_build_without_panic() {
let _router = TrustedServerApp::routes();
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn aps_cutover_renderer_and_family_failures_are_local() {
+ let renderer = request_builder()
+ .method("GET")
+ .uri("/integrations/aps/renderer/v1")
+ .header("authorization", "Bearer must-not-reach-publisher")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build APS renderer request");
+ let response = route_reserved(renderer).await;
+ assert_eq!(response.status().as_u16(), 200);
+ assert_eq!(
+ response.headers()["content-type"],
+ "text/html; charset=utf-8"
+ );
+ assert_eq!(
+ response.headers()["cache-control"],
+ "public, max-age=31536000, immutable"
+ );
+ assert!(!response.headers().contains_key("x-frame-options"));
+ let body = response.into_body().into_bytes().unwrap_or_default();
+ let body = std::str::from_utf8(&body).expect("renderer should be UTF-8");
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(!body.contains("client.aps.amazon-adsystem.com"));
+
+ for (method, path, expected) in [
+ ("POST", "/integrations/aps/runner.js", 405),
+ ("TRACE", "/integrations/aps/renderer/v1", 405),
+ ("CONNECT", "/integrations/aps/renderer/v1", 405),
+ ("PROPFIND", "/integrations/aps/renderer/v1", 405),
+ ("GET", "/integrations/aps/renderer", 404),
+ ("GET", "/integrations/aps/renderer/v2", 404),
+ ("GET", "/integrations/aps/runner/v1.js", 404),
+ ("GET", "/integrations/aps", 404),
+ ] {
+ let request = request_builder()
+ .method(method)
+ .uri(path)
+ .header("authorization", "Bearer must-not-reach-publisher")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build APS family request");
+ let response = route_reserved(request).await;
+ assert_eq!(response.status().as_u16(), expected, "{method} {path}");
+ assert_eq!(response.headers()["cache-control"], "no-store");
+ assert!(!response.headers().contains_key("x-geo-info-available"));
+ if expected == 405 {
+ assert_eq!(response.headers()["allow"], "GET");
+ assert_eq!(response.headers().len(), 2, "{method} {path}");
+ } else {
+ assert_eq!(response.headers().len(), 1, "{method} {path}");
+ }
+ assert!(
+ response
+ .into_body()
+ .into_bytes()
+ .unwrap_or_default()
+ .is_empty()
+ );
+ }
+
+ let protected_control = request_builder()
+ .method("GET")
+ .uri("/integrations/apsx")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build protected non-APS boundary request");
+ let response = route(test_router(), protected_control).await;
+ assert_eq!(response.status().as_u16(), 401);
+}
+
#[test]
fn edgezero_manifest_loads_and_resolves_spin_stores() {
let loader = edgezero_core::manifest::ManifestLoader::load_from_str(include_str!(
@@ -209,6 +296,35 @@ async fn tsjs_route_is_routed_not_5xx() {
assert!(status < 500, "tsjs route must not 5xx: got {status}");
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn tsjs_wrong_methods_are_local_no_store_404s() {
+ for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] {
+ let req = request_builder()
+ .method(method)
+ .uri(format!(
+ "/static/tsjs=tsjs-unified.min.js?v={}",
+ "0".repeat(64)
+ ))
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build wrong-method TSJS request");
+ let response = route(test_router(), req).await;
+
+ assert_eq!(response.status().as_u16(), 404, "method {method}");
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("no-store"),
+ "method {method}"
+ );
+ assert!(
+ !response.headers().contains_key("location"),
+ "method {method}"
+ );
+ }
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn verify_signature_is_routed() {
let router = test_router();
@@ -330,54 +446,30 @@ async fn auction_is_routed() {
assert_ne!(resp.status().as_u16(), 404, "/auction must be routed");
}
-/// `GET` on the SPA re-auction endpoint must reach the page-bids handler on
-/// both the canonical path and its deprecated `/__ts/` alias.
-///
-/// The alias is what pre-rename tsjs bundles still request, and on a SPA that
-/// path is what delivers ads for in-session navigations — so a dropped or
-/// misspelled registration silently costs revenue rather than erroring loudly.
-/// Spin registers `GET` and `OPTIONS` separately, so the preflight-denial parity
-/// test does not imply the `GET` side is wired.
-///
-/// Paths are literals rather than `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`:
-/// this pins the actual URL the client fetches, which asserting a const against
-/// itself would not.
-///
-/// These test settings configure no creative opportunities, so the handler's own
-/// deterministic answer is a 404 `Creative opportunities not configured`. That
-/// body is the anchor: an unregistered path would instead fall through to the
-/// publisher fallback and attempt an outbound fetch to the (nonexistent) test
-/// origin, which cannot produce this message. A bare `!= 404` check would be
-/// wrong here — the handler legitimately returns 404 under this config.
+/// The canonical SPA re-auction path reaches page-bids, while the removed
+/// double-underscore alias is denied locally with 404.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn page_bids_get_is_routed_on_canonical_path_and_alias() {
- let mut responses = Vec::new();
-
- for path in ["/_ts/page-bids", "/__ts/page-bids"] {
- let req = request_builder()
- .method("GET")
- .uri(path)
- .header("sec-fetch-site", "same-origin")
- .body(edgezero_core::body::Body::empty())
- .expect("should build request");
- let resp = route(test_router(), req).await;
- let status = resp.status().as_u16();
- let body = String::from_utf8_lossy(&resp.into_body().into_bytes().unwrap_or_default())
+async fn page_bids_get_is_routed_only_on_the_canonical_path() {
+ let canonical = request_builder()
+ .method("GET")
+ .uri("/_ts/page-bids")
+ .header("sec-fetch-site", "same-origin")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build request");
+ let canonical = route(test_router(), canonical).await;
+ let canonical_body =
+ String::from_utf8_lossy(&canonical.into_body().into_bytes().unwrap_or_default())
.into_owned();
+ assert!(canonical_body.contains("Creative opportunities not configured"));
- assert!(
- body.contains("Creative opportunities not configured"),
- "GET {path} must reach the page-bids handler, \
- got status {status} body {body:?}"
- );
-
- responses.push((status, body));
- }
-
- assert_eq!(
- responses[0], responses[1],
- "the deprecated alias must answer identically to the canonical path"
- );
+ let former_alias = request_builder()
+ .method("GET")
+ .uri("/__ts/page-bids")
+ .header("sec-fetch-site", "same-origin")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build request");
+ let former_alias = route(test_router(), former_alias).await;
+ assert_eq!(former_alias.status().as_u16(), 404);
}
// ---------------------------------------------------------------------------
diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs
index 7c1303dd4..dd78a1538 100644
--- a/crates/trusted-server-core/benches/html_processor_bench.rs
+++ b/crates/trusted-server-core/benches/html_processor_bench.rs
@@ -13,6 +13,7 @@ fn make_config() -> HtmlProcessorConfig {
ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)),
max_buffered_body_bytes: 16 * 1024 * 1024,
gpt_diagnostics: None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
}
}
diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs
index c4af6fd3d..d0c498aa8 100644
--- a/crates/trusted-server-core/src/auction/endpoints.rs
+++ b/crates/trusted-server-core/src/auction/endpoints.rs
@@ -1,6 +1,6 @@
//! HTTP endpoint handlers for auction requests.
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use edgezero_core::body::Body as EdgeBody;
use error_stack::{Report, ResultExt};
@@ -19,20 +19,21 @@ use crate::ec::log_id;
use crate::ec::prebid_eids::parse_prebid_eids_cookie;
use crate::ec::registry::PartnerRegistry;
use crate::error::TrustedServerError;
+use crate::http_util::RequestInfo;
use crate::openrtb::{Eid, Uid};
use crate::platform::RuntimeServices;
use crate::settings::Settings;
use super::AuctionOrchestrator;
-use super::formats::{
- convert_to_openrtb_response, convert_to_openrtb_response_with_report,
- convert_tsjs_to_auction_request,
-};
+use super::formats::{attach_auction_response_headers, convert_tsjs_to_auction_request};
use super::telemetry::{
AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events,
emit_auction_events_best_effort_lazy,
};
-use super::types::AuctionContext;
+use super::types::{
+ AuctionContext, AuctionDecisionSetV1, AuctionRequest, AuctionSlotFailureReason,
+ SlotAuctionDecisionV1, SystemAuctionIdentityGenerator,
+};
const MAX_CLIENT_EID_SOURCES: usize = 64;
const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32;
@@ -44,6 +45,66 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255;
/// arbitrary WASM linear memory.
const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024;
+struct ExactAuctionResponseV1 {
+ response: Response,
+ delivered_winner_slots: HashSet,
+ dropped_winner_count: usize,
+}
+
+fn exact_auction_response_v1(
+ result: &OrchestrationResult,
+ settings: &Settings,
+ auction_request: &AuctionRequest,
+ request_origin: &str,
+ ec_allowed: bool,
+) -> Result> {
+ let price_granularity = settings
+ .creative_opportunities
+ .as_ref()
+ .map(|config| config.price_granularity)
+ .unwrap_or_default();
+ let canonical = crate::publisher::coordinated_cutover_v1::build_browser_auction_projection_v1(
+ result,
+ price_granularity,
+ settings,
+ request_origin,
+ None,
+ &SystemAuctionIdentityGenerator,
+ )?;
+ let body = crate::auction::formats::coordinated_cutover_v1::serialize_trusted_server_auction_response_v1(
+ &canonical,
+ )?;
+ let delivered_winner_slots: HashSet = canonical
+ .projection
+ .auction
+ .results
+ .iter()
+ .filter_map(|decision| match decision {
+ SlotAuctionDecisionV1::Winner { slot, .. } => Some(slot.clone()),
+ _ => None,
+ })
+ .collect();
+ let projected_winner_count = result
+ .decision_set
+ .results
+ .iter()
+ .filter(|decision| matches!(decision, SlotAuctionDecisionV1::Winner { .. }))
+ .count();
+ let mut response = Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(EdgeBody::from(body))
+ .change_context(TrustedServerError::Auction {
+ message: "Failed to build exact auction response".to_string(),
+ })?;
+ attach_auction_response_headers(&mut response, auction_request, ec_allowed)?;
+ Ok(ExactAuctionResponseV1 {
+ response,
+ dropped_winner_count: projected_winner_count.saturating_sub(delivered_winner_slots.len()),
+ delivered_winner_slots,
+ })
+}
+
/// Handle auction request from `POST /auction`.
///
/// Accepts a JSON body matching [`AdRequest`][`super::formats::AdRequest`].
@@ -167,6 +228,22 @@ pub async fn handle_auction(
);
let http_req = Request::from_parts(parts, EdgeBody::empty());
+ let request_info = RequestInfo::from_request(&http_req, services.client_info());
+ let request_scheme = if request_info.scheme.is_empty() {
+ http_req.uri().scheme_str().unwrap_or("https")
+ } else {
+ &request_info.scheme
+ };
+ let request_host = if request_info.host.is_empty() {
+ http_req
+ .uri()
+ .authority()
+ .map(http::uri::Authority::as_str)
+ .unwrap_or(&settings.publisher.domain)
+ } else {
+ &request_info.host
+ };
+ let request_origin = format!("{request_scheme}://{request_host}");
// Story 5 middleware contract: auction is a read-only EC route.
// It must not generate EC IDs; it only consumes pre-routed context.
@@ -220,15 +297,21 @@ pub async fn handle_auction(
provider_responses: Vec::new(),
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1::failed(
+ &auction_request,
+ AuctionSlotFailureReason::ConsentDenied,
+ ),
total_time_ms: 0,
metadata: HashMap::new(),
};
- return convert_to_openrtb_response(
+ return Ok(exact_auction_response_v1(
&empty_result,
settings,
&auction_request,
+ &request_origin,
ec_context.ec_allowed(),
- );
+ )?
+ .response);
}
// Parse client-provided EIDs from the current request body. When the
@@ -325,10 +408,11 @@ pub async fn handle_auction(
}
};
- let conversion = match convert_to_openrtb_response_with_report(
+ let conversion = match exact_auction_response_v1(
&result,
settings,
&auction_request,
+ &request_origin,
ec_context.ec_allowed(),
) {
Ok(conversion) => conversion,
@@ -356,7 +440,7 @@ pub async fn handle_auction(
AuctionTerminalOutcome::Completed {
request: &auction_request,
result: &result,
- delivered_winner_slots: Some(&conversion.delivery.delivered_winner_slots),
+ delivered_winner_slots: Some(&conversion.delivered_winner_slots),
},
)
})
@@ -365,8 +449,8 @@ pub async fn handle_auction(
log::info!(
"Auction completed: {} providers, {} delivered winning bids, {} dropped winners, {}ms total",
result.provider_responses.len(),
- conversion.delivery.delivered_winner_slots.len(),
- conversion.delivery.dropped_winner_count,
+ conversion.delivered_winner_slots.len(),
+ conversion.dropped_winner_count,
result.total_time_ms
);
@@ -740,6 +824,20 @@ mod tests {
seatbid_empty,
"gated auction must return no bids, got: {parsed}"
);
+ assert_eq!(parsed["cur"], "USD");
+ assert_eq!(
+ parsed["ext"]["trusted_server"]["slot_results"]["results"][0],
+ json!({
+ "slot": "div-gpt-ad-1",
+ "outcome": "failed",
+ "reason": "consent_denied"
+ }),
+ "the production endpoint must emit the exact decision-set extension"
+ );
+ assert!(
+ parsed["ext"].get("orchestrator").is_none(),
+ "the removed legacy response extension must not survive the hard cutover"
+ );
let batches = telemetry_sink
.batches
diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs
index e09912aab..69db694f8 100644
--- a/crates/trusted-server-core/src/auction/formats.rs
+++ b/crates/trusted-server-core/src/auction/formats.rs
@@ -7,7 +7,7 @@
use edgezero_core::body::Body as EdgeBody;
use error_stack::{Report, ResultExt, ensure};
use http::{HeaderValue, Request, Response, StatusCode, header};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, HashMap, HashSet};
use url::Url;
@@ -29,8 +29,12 @@ use crate::settings::Settings;
use super::orchestrator::OrchestrationResult;
use super::types::{
- AdFormat, AdSlot, AuctionRequest, BidRenderer, DeviceInfo, MediaType, OrchestratorExt,
- ProviderSummary, PublisherInfo, SiteInfo, UserInfo,
+ AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest,
+ AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1,
+ BrowserAuctionSlotV1, DeviceInfo, MAX_BROWSER_AUCTION_PROJECTION_BYTES,
+ MAX_BROWSER_AUCTION_RESULTS, MAX_BROWSER_AUCTION_TARGETING_ENTRIES, MediaType, OrchestratorExt,
+ ProviderSummary, PublisherInfo, RENDER_DIMENSION_MAX, RENDER_DIMENSION_MIN, SiteInfo,
+ SlotAuctionDecisionV1, UserInfo, classify_aps_renderer_v1, record_auction_drop,
};
/// Request body for `POST /auction` (tsjs / Prebid.js wire format).
@@ -281,6 +285,499 @@ pub fn convert_tsjs_to_auction_request(
})
}
+/// Attach the consent/EID headers shared by every `/auction` response wire.
+pub(crate) fn attach_auction_response_headers(
+ response: &mut Response,
+ auction_request: &AuctionRequest,
+ ec_allowed: bool,
+) -> Result<(), Report> {
+ if ec_allowed {
+ response
+ .headers_mut()
+ .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok"));
+ }
+
+ if let Some(ref eids) = auction_request.user.eids {
+ let (encoded, truncated) = encode_eids_header(eids)?;
+ let header_val =
+ HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction {
+ message: "Failed to encode EIDs header value".to_string(),
+ })?;
+ response.headers_mut().insert(HEADER_X_TS_EIDS, header_val);
+ if truncated {
+ response
+ .headers_mut()
+ .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true"));
+ }
+ }
+
+ Ok(())
+}
+
+#[allow(
+ dead_code,
+ reason = "pure coordinated-cutover contract is exercised directly until Task 19 wires endpoints"
+)]
+pub(crate) mod coordinated_cutover_v1 {
+ use super::*;
+
+ /// Validated projection plus its exact canonical UTF-8 representation.
+ #[derive(Debug, Clone)]
+ pub(crate) struct CanonicalBrowserAuctionProjectionV1 {
+ /// Deep-owned, validated projection in canonical result/bid/targeting order.
+ pub projection: BrowserAuctionProjectionV1,
+ /// Whitespace-free JSON using schema field order.
+ pub json: Vec,
+ /// Whether the exact aggregate overflow rule replaced every winner.
+ pub reduced_for_size: bool,
+ }
+
+ fn projection_contract_error(message: impl Into) -> Report {
+ Report::new(TrustedServerError::Auction {
+ message: message.into(),
+ })
+ }
+
+ fn is_base64url_byte(byte: u8) -> bool {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
+ }
+
+ fn valid_auction_id(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= 128
+ && value.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')
+ })
+ }
+
+ fn valid_candidate_id(value: &str) -> bool {
+ value.len() == 12 && value.bytes().all(is_base64url_byte)
+ }
+
+ fn valid_renderer_reservation_id(value: &str) -> bool {
+ value
+ .strip_prefix("r1_")
+ .is_some_and(|token| token.len() == 22 && token.bytes().all(is_base64url_byte))
+ }
+
+ fn valid_provider_name(value: &str) -> bool {
+ let bytes = value.as_bytes();
+ (1..=64).contains(&bytes.len())
+ && bytes[0].is_ascii_alphanumeric()
+ && bytes[1..]
+ .iter()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b'-'))
+ }
+
+ fn valid_bounded_text(value: &str, maximum_bytes: usize) -> bool {
+ !value.is_empty()
+ && value.len() <= maximum_bytes
+ && !value
+ .chars()
+ .any(|character| matches!(character, '\0'..='\u{1f}' | '\u{7f}'))
+ }
+
+ fn valid_targeting_key(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= 20
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
+ }
+
+ fn valid_targeting(targeting: &BTreeMap) -> bool {
+ targeting.len() <= MAX_BROWSER_AUCTION_TARGETING_ENTRIES
+ && targeting.iter().all(|(key, value)| {
+ key != "hb_adid"
+ && valid_targeting_key(key)
+ && valid_bounded_text(value, 160)
+ && value.chars().count() <= 40
+ })
+ }
+
+ fn valid_render_dimension(value: u32) -> bool {
+ (RENDER_DIMENSION_MIN..=RENDER_DIMENSION_MAX).contains(&u64::from(value))
+ }
+
+ fn render_source_dimensions(source: &BidRenderSourceV1) -> (u32, u32) {
+ match source {
+ BidRenderSourceV1::Aps(source) => (source.width, source.height),
+ BidRenderSourceV1::Adm(source) => (source.width, source.height),
+ BidRenderSourceV1::PbsCache(source) => (source.width, source.height),
+ }
+ }
+
+ fn valid_render_source(source: &BidRenderSourceV1, publisher_origin: &str) -> bool {
+ match source {
+ BidRenderSourceV1::Aps(source) => {
+ valid_render_dimension(source.width)
+ && valid_render_dimension(source.height)
+ && source.version == 1
+ && serde_json::to_value(BidRenderSourceV1::Aps(source.clone())).is_ok_and(
+ |value| {
+ classify_aps_renderer_v1(&value, publisher_origin)
+ == crate::auction::types::ApsRendererValidationResult::Accepted
+ },
+ )
+ }
+ BidRenderSourceV1::Adm(source) => {
+ valid_render_dimension(source.width)
+ && valid_render_dimension(source.height)
+ && source.version == 1
+ && !source.adm.is_empty()
+ && source.adm.len() <= 512 * 1024
+ }
+ BidRenderSourceV1::PbsCache(source) => {
+ source.version == 1
+ && !source.cache_id.is_empty()
+ && !source.cache_host.is_empty()
+ && !source.cache_path.is_empty()
+ }
+ }
+ }
+
+ fn valid_browser_bid(bid: &BrowserAuctionBidV1, publisher_origin: &str) -> bool {
+ valid_candidate_id(&bid.candidate_id)
+ && valid_bounded_text(&bid.slot, 256)
+ && valid_provider_name(&bid.provider)
+ && valid_bounded_text(&bid.upstream_bid_id, 64)
+ && bid.cpm.is_finite()
+ && bid.cpm >= 0.0
+ && bid.currency == "USD"
+ && valid_targeting(&bid.targeting)
+ && valid_render_source(&bid.render_source, publisher_origin)
+ && match &bid.render_source {
+ BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => bid
+ .renderer_reservation_id
+ .as_deref()
+ .is_some_and(valid_renderer_reservation_id),
+ BidRenderSourceV1::PbsCache(_) => bid.renderer_reservation_id.is_none(),
+ }
+ }
+
+ fn valid_browser_slot(slot: &BrowserAuctionSlotV1) -> bool {
+ valid_bounded_text(&slot.slot, 256)
+ && valid_bounded_text(&slot.gam_unit_path, 256)
+ && valid_bounded_text(&slot.div_id, 256)
+ && !slot.formats.is_empty()
+ && slot.formats.len() <= 64
+ && slot.formats.iter().all(|[width, height]| {
+ valid_render_dimension(*width) && valid_render_dimension(*height)
+ })
+ && valid_targeting(&slot.targeting)
+ }
+
+ fn validate_decision_set(
+ decision_set: &AuctionDecisionSetV1,
+ ) -> Result<(), Report> {
+ ensure!(
+ decision_set.version == 1,
+ projection_contract_error("Browser auction decision version must be 1")
+ );
+ ensure!(
+ valid_auction_id(&decision_set.auction_id),
+ projection_contract_error("Browser auction id violates the version-1 grammar")
+ );
+ ensure!(
+ decision_set.results.len() <= MAX_BROWSER_AUCTION_RESULTS,
+ projection_contract_error("Browser auction result count exceeds 256")
+ );
+
+ let mut slots = HashSet::new();
+ let mut candidates = HashSet::new();
+ for result in &decision_set.results {
+ ensure!(
+ valid_bounded_text(result.slot(), 256) && slots.insert(result.slot()),
+ projection_contract_error("Browser auction result slots must be valid and unique")
+ );
+ if let SlotAuctionDecisionV1::Winner { candidate_id, .. } = result {
+ ensure!(
+ valid_candidate_id(candidate_id) && candidates.insert(candidate_id),
+ projection_contract_error(
+ "Browser auction winner candidates must be valid and unique"
+ )
+ );
+ }
+ }
+ Ok(())
+ }
+
+ /// Validate, reorder, and canonically serialize a complete browser auction projection.
+ ///
+ /// Winner-local projection failures become `winner_not_renderable`. Aggregate
+ /// overflow applies the contract's all-winners reduction; it never selects a
+ /// response-order-dependent subset.
+ pub(crate) fn canonicalize_browser_auction_projection_v1(
+ input: BrowserAuctionProjectionV1,
+ publisher_origin: &str,
+ ) -> Result> {
+ ensure!(
+ input.version == 1,
+ projection_contract_error("Browser auction projection version must be 1")
+ );
+ validate_decision_set(&input.auction)?;
+ ensure!(
+ input.slots.len() <= MAX_BROWSER_AUCTION_RESULTS,
+ projection_contract_error("Browser auction slot count exceeds 256")
+ );
+ if !input.slots.is_empty() {
+ ensure!(
+ input.slots.len() == input.auction.results.len(),
+ projection_contract_error(
+ "Browser auction slots must cover every decision or be empty for direct serialization"
+ )
+ );
+ let mut slot_ids = HashSet::with_capacity(input.slots.len());
+ for (index, slot) in input.slots.iter().enumerate() {
+ ensure!(
+ valid_browser_slot(slot)
+ && slot_ids.insert(slot.slot.as_str())
+ && input.auction.results[index].slot() == slot.slot,
+ projection_contract_error(
+ "Browser auction slots must be valid, unique, and follow decision order"
+ )
+ );
+ }
+ }
+ ensure!(
+ input.bids.len() <= MAX_BROWSER_AUCTION_RESULTS,
+ projection_contract_error("Browser auction bid count exceeds 256")
+ );
+
+ let publisher_origin = Url::parse(publisher_origin)
+ .ok()
+ .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
+ .map(|url| url.origin().ascii_serialization())
+ .ok_or_else(|| projection_contract_error("Publisher origin is invalid"))?;
+
+ let mut bids_by_candidate = HashMap::with_capacity(input.bids.len());
+ for bid in input.bids {
+ let candidate_id = bid.candidate_id.clone();
+ ensure!(
+ bids_by_candidate.insert(candidate_id, bid).is_none(),
+ projection_contract_error("Browser auction candidate bids must be unique")
+ );
+ }
+
+ let mut reservation_ids = HashSet::new();
+ let mut canonical_bids = Vec::new();
+ let mut canonical_results = Vec::with_capacity(input.auction.results.len());
+ for result in input.auction.results {
+ match result {
+ SlotAuctionDecisionV1::Winner { slot, candidate_id } => {
+ let bid = bids_by_candidate.remove(&candidate_id);
+ if let Some(bid) = bid.filter(|bid| {
+ bid.slot == slot
+ && valid_browser_bid(bid, &publisher_origin)
+ && bid
+ .renderer_reservation_id
+ .as_ref()
+ .is_none_or(|id| reservation_ids.insert(id.clone()))
+ }) {
+ canonical_results
+ .push(SlotAuctionDecisionV1::Winner { slot, candidate_id });
+ canonical_bids.push(bid);
+ } else {
+ canonical_results.push(SlotAuctionDecisionV1::Failed {
+ slot,
+ reason: AuctionSlotFailureReason::WinnerNotRenderable,
+ });
+ }
+ }
+ non_winner => canonical_results.push(non_winner),
+ }
+ }
+ ensure!(
+ bids_by_candidate.is_empty(),
+ projection_contract_error("Browser auction contains a bid without a winner decision")
+ );
+
+ let mut projection = BrowserAuctionProjectionV1 {
+ version: 1,
+ auction: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: input.auction.auction_id,
+ results: canonical_results,
+ },
+ slots: input.slots,
+ bids: canonical_bids,
+ };
+ let mut json =
+ serde_json::to_vec(&projection).change_context(TrustedServerError::Auction {
+ message: "Failed to serialize browser auction projection".to_string(),
+ })?;
+ let reduced_for_size = json.len() > MAX_BROWSER_AUCTION_PROJECTION_BYTES;
+ if reduced_for_size {
+ projection.auction.results = projection
+ .auction
+ .results
+ .into_iter()
+ .map(|result| match result {
+ SlotAuctionDecisionV1::Winner { slot, .. } => SlotAuctionDecisionV1::Failed {
+ slot,
+ reason: AuctionSlotFailureReason::WinnerNotRenderable,
+ },
+ non_winner => non_winner,
+ })
+ .collect();
+ projection.bids.clear();
+ json = serde_json::to_vec(&projection).change_context(TrustedServerError::Auction {
+ message: "Failed to serialize reduced browser auction projection".to_string(),
+ })?;
+ ensure!(
+ json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES,
+ projection_contract_error("Reduced browser auction projection exceeds 8 MiB")
+ );
+ }
+
+ Ok(CanonicalBrowserAuctionProjectionV1 {
+ projection,
+ json,
+ reduced_for_size,
+ })
+ }
+
+ /// Parse and validate one browser-boot projection before it enters HTML.
+ ///
+ /// Browser boot requires full slot coverage, unlike the direct `/auction`
+ /// serializer that may carry an empty slot vector. The result is the exact
+ /// canonical JSON produced by the shared production validator.
+ pub(crate) fn canonicalize_browser_auction_projection_json_v1(
+ json: &str,
+ publisher_origin: &str,
+ ) -> Result> {
+ ensure!(
+ json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES,
+ projection_contract_error("Browser auction projection exceeds 8 MiB")
+ );
+ let projection =
+ serde_json::from_str::(json).map_err(|_| {
+ projection_contract_error(
+ "Browser auction projection violates the version-1 schema",
+ )
+ })?;
+ let canonical =
+ canonicalize_browser_auction_projection_v1(projection.clone(), publisher_origin)?;
+ ensure!(
+ !canonical.reduced_for_size && canonical.projection == projection,
+ projection_contract_error("Browser auction projection violates the version-1 contract")
+ );
+ String::from_utf8(canonical.json).map_err(|_| {
+ projection_contract_error("Browser auction projection serialization is not UTF-8")
+ })
+ }
+
+ #[derive(Serialize)]
+ struct TrustedServerOpenRtbBidExtV1<'a> {
+ candidate_id: &'a str,
+ slot_id: &'a str,
+ render_source: &'a BidRenderSourceV1,
+ }
+
+ #[derive(Serialize)]
+ struct OpenRtbBidExtV1<'a> {
+ trusted_server: TrustedServerOpenRtbBidExtV1<'a>,
+ }
+
+ #[derive(Serialize)]
+ struct TrustedServerOpenRtbBidV1<'a> {
+ id: &'a str,
+ impid: &'a str,
+ price: f64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ adm: Option<&'a str>,
+ w: u32,
+ h: u32,
+ ext: OpenRtbBidExtV1<'a>,
+ }
+
+ #[derive(Serialize)]
+ struct TrustedServerSeatBidV1<'a> {
+ seat: &'a str,
+ bid: Vec>,
+ }
+
+ #[derive(Serialize)]
+ struct TrustedServerResponseExtInnerV1<'a> {
+ slot_results: &'a AuctionDecisionSetV1,
+ }
+
+ #[derive(Serialize)]
+ struct TrustedServerResponseExtV1<'a> {
+ trusted_server: TrustedServerResponseExtInnerV1<'a>,
+ }
+
+ #[derive(Serialize)]
+ struct TrustedServerAuctionResponseWireV1<'a> {
+ id: &'a str,
+ seatbid: Vec>,
+ cur: &'static str,
+ ext: TrustedServerResponseExtV1<'a>,
+ }
+
+ /// Serialize the coordinated-cutover exact `/auction` winner wire.
+ ///
+ /// This remains a pure contract function until Task 19 switches the endpoint.
+ pub(crate) fn serialize_trusted_server_auction_response_v1(
+ canonical: &CanonicalBrowserAuctionProjectionV1,
+ ) -> Result, Report> {
+ let seatbid = canonical
+ .projection
+ .bids
+ .iter()
+ .map(|bid| {
+ let (width, height) = render_source_dimensions(&bid.render_source);
+ let wire_id = match &bid.render_source {
+ BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => bid
+ .renderer_reservation_id
+ .as_deref()
+ .expect("should retain the validated APS/ADM reservation"),
+ BidRenderSourceV1::PbsCache(source) => source.cache_id.as_str(),
+ };
+ TrustedServerSeatBidV1 {
+ seat: &bid.provider,
+ bid: vec![TrustedServerOpenRtbBidV1 {
+ id: wire_id,
+ impid: &bid.slot,
+ price: bid.cpm,
+ // `render_source` is the sole browser authority. Standard
+ // `adm` is optional on the exact wire and omitted by the
+ // producer to avoid duplicating up to 512 KiB per winner.
+ adm: None,
+ w: width,
+ h: height,
+ ext: OpenRtbBidExtV1 {
+ trusted_server: TrustedServerOpenRtbBidExtV1 {
+ candidate_id: &bid.candidate_id,
+ slot_id: &bid.slot,
+ render_source: &bid.render_source,
+ },
+ },
+ }],
+ }
+ })
+ .collect();
+ let response = TrustedServerAuctionResponseWireV1 {
+ id: &canonical.projection.auction.auction_id,
+ seatbid,
+ cur: "USD",
+ ext: TrustedServerResponseExtV1 {
+ trusted_server: TrustedServerResponseExtInnerV1 {
+ slot_results: &canonical.projection.auction,
+ },
+ },
+ };
+ serde_json::to_vec(&response).change_context(TrustedServerError::Auction {
+ message: "Failed to serialize exact trusted-server auction response".to_string(),
+ })
+ }
+}
+
+#[cfg(test)]
+use coordinated_cutover_v1::{
+ canonicalize_browser_auction_projection_v1, serialize_trusted_server_auction_response_v1,
+};
+
/// Delivery facts produced while serializing winning bids.
#[derive(Debug, Default)]
pub(crate) struct AuctionDeliveryReport {
@@ -289,20 +786,11 @@ pub(crate) struct AuctionDeliveryReport {
/// Winners omitted because they could not be delivered safely.
pub dropped_winner_count: usize,
/// Machine-readable reasons for omitted winners.
- pub dropped_winner_reasons: BTreeMap,
-}
-
-impl AuctionDeliveryReport {
- fn record_drop(&mut self, reason: &str) {
- self.dropped_winner_count += 1;
- *self
- .dropped_winner_reasons
- .entry(reason.to_string())
- .or_default() += 1;
- }
+ pub dropped_winner_reasons: AuctionDropReasons,
}
/// Serialized response and the delivery facts used to produce it.
+#[cfg(test)]
pub(crate) struct OpenRtbResponseConversion {
/// HTTP response returned to the auction client.
pub response: Response,
@@ -317,38 +805,63 @@ pub(crate) struct OpenRtbResponseConversion {
/// ([`AuctionConfig::sanitize_creatives`], opt-in, and
/// [`AuctionConfig::rewrite_creatives`], default-on); with both disabled the
/// creative ships exactly as the bidder returned it, subject to the 1 MiB
-/// per-creative cap. Typed renderers are serialized in the response extension
-/// instead of entering that pipeline at all.
+/// per-creative cap.
///
/// [`AuctionConfig::sanitize_creatives`]: crate::auction_config_types::AuctionConfig::sanitize_creatives
/// [`AuctionConfig::rewrite_creatives`]: crate::auction_config_types::AuctionConfig::rewrite_creatives
///
/// # Errors
///
-/// Returns an error if response serialization fails.
-///
-/// Winners without a decoded price or a deliverable creative are omitted and
-/// recorded in the returned delivery report so other slots can still render.
+/// Returns an error if:
+/// - A winning bid is missing a price or render source
+/// - The response serialization fails
pub fn convert_to_openrtb_response(
result: &OrchestrationResult,
settings: &Settings,
auction_request: &AuctionRequest,
ec_allowed: bool,
) -> Result, Report> {
- Ok(
- convert_to_openrtb_response_with_report(result, settings, auction_request, ec_allowed)?
- .response,
- )
+ convert_to_openrtb_response_impl(result, settings, auction_request, ec_allowed)
}
+#[cfg(test)]
pub(crate) fn convert_to_openrtb_response_with_report(
result: &OrchestrationResult,
settings: &Settings,
auction_request: &AuctionRequest,
ec_allowed: bool,
) -> Result> {
+ let (response, delivery) = convert_to_openrtb_response_impl_with_report(
+ result,
+ settings,
+ auction_request,
+ ec_allowed,
+ )?;
+ Ok(OpenRtbResponseConversion { response, delivery })
+}
+
+fn convert_to_openrtb_response_impl(
+ result: &OrchestrationResult,
+ settings: &Settings,
+ auction_request: &AuctionRequest,
+ ec_allowed: bool,
+) -> Result, Report> {
+ let (response, _) = convert_to_openrtb_response_impl_with_report(
+ result,
+ settings,
+ auction_request,
+ ec_allowed,
+ )?;
+ Ok(response)
+}
+
+fn convert_to_openrtb_response_impl_with_report(
+ result: &OrchestrationResult,
+ settings: &Settings,
+ auction_request: &AuctionRequest,
+ ec_allowed: bool,
+) -> Result<(Response, AuctionDeliveryReport), Report> {
let mut seatbids = Vec::with_capacity(result.winning_bids.len());
- let rewrite_creatives = settings.auction.rewrite_creatives;
let mut delivery = AuctionDeliveryReport::default();
for (slot_id, bid) in &result.winning_bids {
@@ -359,7 +872,11 @@ pub(crate) fn convert_to_openrtb_response_with_report(
slot_id,
bid.bidder
);
- delivery.record_drop("no_decoded_price");
+ delivery.dropped_winner_count += 1;
+ record_auction_drop(
+ &mut delivery.dropped_winner_reasons,
+ AuctionDropReason::InvalidPrice,
+ );
continue;
};
@@ -370,29 +887,29 @@ pub(crate) fn convert_to_openrtb_response_with_report(
let width = to_openrtb_i32(bid.width, "width", &bid_context);
let height = to_openrtb_i32(bid.height, "height", &bid_context);
- // Ordinary markup goes through the configured creative processing:
- // sanitization is opt-in, rewriting is on by default, and with both
- // disabled the creative ships exactly as the bidder returned it. A typed
- // renderer is serialized separately and never enters that pipeline.
- let serialize_renderer = |renderer: &BidRenderer| {
- (BidExt {
- trusted_server: BidTrustedServerExt { renderer },
- })
- .to_ext()
- };
- let (adm, ext) = if let Some(raw_creative) = bid
+ let creative = bid
.creative
.as_deref()
- .filter(|creative| !creative.trim().is_empty())
- {
- if bid.renderer.is_some() {
- log::warn!(
- "Auction {}: winning bid for slot '{}' from '{}' has both creative markup and a renderer; using creative markup when it remains renderable",
- auction_request.id,
- slot_id,
- bid.bidder
- );
- }
+ .filter(|creative| !creative.trim().is_empty());
+ if creative.is_some() && bid.renderer.is_some() {
+ log::warn!(
+ "Auction {}: skipping winning bid for slot '{}' from '{}' because it has multiple render sources",
+ auction_request.id,
+ slot_id,
+ bid.bidder
+ );
+ delivery.dropped_winner_count += 1;
+ record_auction_drop(
+ &mut delivery.dropped_winner_reasons,
+ AuctionDropReason::MultipleRenderSources,
+ );
+ continue;
+ }
+
+ // Ordinary markup follows the independently configured processing
+ // path: sanitization is opt-in and rewriting is default-on. A typed
+ // render source is serialized separately and never enters either pass.
+ let (adm, ext) = if let Some(raw_creative) = creative {
let processed = creative::process_auction_creative(settings, raw_creative);
log::debug!(
@@ -401,45 +918,43 @@ pub(crate) fn convert_to_openrtb_response_with_report(
slot_id,
bid.bidder,
settings.auction.sanitize_creatives,
- rewrite_creatives,
+ settings.auction.rewrite_creatives,
raw_creative.len(),
processed.len()
);
if processed.trim().is_empty() {
- let Some(renderer) = bid.renderer.as_ref() else {
- log::warn!(
- "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source",
- auction_request.id,
- slot_id,
- bid.bidder
- );
- delivery.record_drop("creative_processing_rejected");
- continue;
- };
- let Some(ext) = serialize_renderer(renderer) else {
- log::warn!(
- "Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized",
- auction_request.id,
- slot_id,
- bid.bidder
- );
- delivery.record_drop("renderer_extension_serialization_failed");
- continue;
- };
- (None, Some(ext))
- } else {
- (Some(processed), None)
+ log::warn!(
+ "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source",
+ auction_request.id,
+ slot_id,
+ bid.bidder
+ );
+ delivery.dropped_winner_count += 1;
+ record_auction_drop(
+ &mut delivery.dropped_winner_reasons,
+ AuctionDropReason::CreativeProcessingRejected,
+ );
+ continue;
}
+
+ (Some(processed), None)
} else if let Some(renderer) = bid.renderer.as_ref() {
- let Some(ext) = serialize_renderer(renderer) else {
+ let Some(ext) = (BidExt {
+ trusted_server: BidTrustedServerExt { renderer },
+ })
+ .to_ext() else {
log::warn!(
"Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized",
auction_request.id,
slot_id,
bid.bidder
);
- delivery.record_drop("renderer_extension_serialization_failed");
+ delivery.dropped_winner_count += 1;
+ record_auction_drop(
+ &mut delivery.dropped_winner_reasons,
+ AuctionDropReason::RendererExtensionSerializationFailed,
+ );
continue;
};
(None, Some(ext))
@@ -450,7 +965,11 @@ pub(crate) fn convert_to_openrtb_response_with_report(
slot_id,
bid.bidder
);
- delivery.record_drop("no_render_source");
+ delivery.dropped_winner_count += 1;
+ record_auction_drop(
+ &mut delivery.dropped_winner_reasons,
+ AuctionDropReason::NoRenderSource,
+ );
continue;
};
@@ -524,36 +1043,17 @@ pub(crate) fn convert_to_openrtb_response_with_report(
message: "Failed to build auction response".to_string(),
})?;
- // Signal consent status independently of whether EIDs were resolved.
- if ec_allowed {
- response
- .headers_mut()
- .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok"));
- }
-
- // Attach EID response headers when consent-gated EIDs are available.
- if let Some(ref eids) = auction_request.user.eids {
- let (encoded, truncated) = encode_eids_header(eids)?;
- let header_val =
- HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction {
- message: "Failed to encode EIDs header value".to_string(),
- })?;
- response.headers_mut().insert(HEADER_X_TS_EIDS, header_val);
- if truncated {
- response
- .headers_mut()
- .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true"));
- }
- }
+ attach_auction_response_headers(&mut response, auction_request, ec_allowed)?;
- Ok(OpenRtbResponseConversion { response, delivery })
+ Ok((response, delivery))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auction::types::{
- ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus,
+ ApsRendererV1, ApsTagType, AuctionDecisionSetV1, AuctionResponse, Bid, BidRenderSourceV1,
+ BidStatus,
};
use crate::openrtb::{Eid, Uid};
use crate::platform::test_support::noop_services;
@@ -609,6 +1109,11 @@ mod tests {
provider_responses: Vec::new(),
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 10,
metadata: HashMap::new(),
}
@@ -617,6 +1122,9 @@ mod tests {
fn make_bid(slot_id: &str, bidder: &str, price: Option) -> Bid {
Bid {
slot_id: slot_id.to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price,
currency: "USD".to_string(),
creative: Some("Ad
".to_string()),
@@ -657,6 +1165,11 @@ mod tests {
}],
mediator_response: None,
winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
}
@@ -1159,7 +1672,8 @@ mod tests {
#[test]
fn convert_to_openrtb_response_serializes_winning_bid_and_orchestrator_ext() {
- let settings = make_settings();
+ let mut settings = make_settings();
+ settings.auction.rewrite_creatives = false;
let auction_request = make_auction_request();
let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75)));
@@ -1199,18 +1713,7 @@ mod tests {
assert_eq!(bid["id"], json!("appnexus-div-gpt-top"));
assert_eq!(bid["impid"], json!("div-gpt-top"));
assert_eq!(bid["price"], json!(2.75));
- // Rewriting is on by default, and a body-less fragment still receives
- // the creative runtime (prepended), so the markup is carried rather
- // than returned verbatim.
- let adm = bid["adm"].as_str().expect("should serialize adm");
- assert!(
- adm.contains("Ad
"),
- "should carry the creative: {adm}"
- );
- assert!(
- adm.contains("/static/tsjs=tsjs-unified.min.js"),
- "should inject the creative runtime into a body-less fragment: {adm}"
- );
+ assert_eq!(bid["adm"], json!("Ad
"));
assert_eq!(bid["crid"], json!("appnexus-creative"));
assert_eq!(bid["w"], json!(300));
assert_eq!(bid["h"], json!(250));
@@ -1275,7 +1778,7 @@ mod tests {
"should remove malicious script content before rewriting: {adm}"
);
assert!(
- !adm.contains("auction-handler-marker") && !adm.contains("onerror"),
+ !adm.contains("auction-handler-marker") && !adm.contains(r#" onerror=""#),
"should remove event handlers before rewriting: {adm}"
);
}
@@ -1288,7 +1791,6 @@ mod tests {
// markup cannot reach the publisher origin — can opt out and deliver the
// creative exactly as the bidder returned it.
let mut settings = make_settings();
- settings.auction.sanitize_creatives = false;
settings.auction.rewrite_creatives = false;
let auction_request = make_auction_request();
let result = make_result(make_complete_creative_bid());
@@ -1298,7 +1800,7 @@ mod tests {
.expect("should have a creative fixture");
let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
- .expect("should convert creative with sanitization disabled");
+ .expect("should convert creative with rewriting disabled");
let adm = response_adm(response);
assert_eq!(
@@ -1341,7 +1843,7 @@ mod tests {
}
#[test]
- fn sanitize_creatives_defaults_to_disabled() {
+ fn rewrite_creatives_defaults_to_enabled() {
let config = crate::auction_config_types::AuctionConfig::default();
assert!(
!config.sanitize_creatives,
@@ -1355,8 +1857,6 @@ mod tests {
#[test]
fn convert_to_openrtb_response_can_skip_rewriting_while_sanitizing() {
- // The two controls are independent: sanitization can stay on while URL
- // rewriting is off.
let mut settings = make_settings();
settings.auction.rewrite_creatives = false;
settings.auction.sanitize_creatives = true;
@@ -1400,7 +1900,7 @@ mod tests {
"should still remove malicious script content: {adm}"
);
assert!(
- !adm.contains("auction-handler-marker") && !adm.contains("onerror"),
+ !adm.contains("auction-handler-marker") && !adm.contains(r#" onerror=""#),
"should still remove event handlers: {adm}"
);
}
@@ -1432,6 +1932,11 @@ mod tests {
}],
mediator_response: None,
winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
};
@@ -1448,26 +1953,19 @@ mod tests {
#[test]
fn convert_to_openrtb_response_skips_invalid_winners_without_dropping_valid_slots() {
- // Sanitization is opt-in, so enable it here: script-only markup is what
- // makes the `rejected` and `renderer` fixtures below reach the
- // processing-rejected path. Left at the default they would survive
- // processing as ordinary (script-bearing) creatives.
let mut settings = make_settings();
- settings.auction.sanitize_creatives = true;
+ settings.auction.rewrite_creatives = false;
let auction_request = make_auction_request();
let mut missing = make_bid("missing", "invalid", Some(3.0));
missing.creative = None;
let mut whitespace = make_bid("whitespace", "invalid", Some(2.9));
whitespace.creative = Some(" \n\t ".to_string());
- let mut rejected = make_bid("rejected", "invalid", Some(2.8));
- 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));
- renderer.creative = Some("".to_string());
+ renderer.creative = Some(" ".to_string());
renderer.bid_id = Some("upstream-renderer-bid".to_string());
renderer.creative_id = None;
- renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
+ renderer.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "upstream-renderer-bid".to_string(),
@@ -1484,37 +1982,21 @@ mod tests {
winning_bids: HashMap::from([
(missing.slot_id.clone(), missing),
(whitespace.slot_id.clone(), whitespace),
- (rejected.slot_id.clone(), rejected),
- (unpriced.slot_id.clone(), unpriced),
(ordinary.slot_id.clone(), ordinary),
(renderer.slot_id.clone(), renderer),
]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
};
- let conversion =
- convert_to_openrtb_response_with_report(&result, &settings, &auction_request, false)
- .expect("should omit invalid winners and preserve valid slots");
- assert_eq!(
- conversion.delivery.delivered_winner_slots,
- HashSet::from(["ordinary".to_string(), "renderer".to_string()]),
- "should report only serialized winners as delivered"
- );
- assert_eq!(conversion.delivery.dropped_winner_count, 4);
- assert_eq!(
- conversion.delivery.dropped_winner_reasons["no_render_source"],
- 2
- );
- assert_eq!(
- conversion.delivery.dropped_winner_reasons["no_decoded_price"],
- 1
- );
- assert_eq!(
- conversion.delivery.dropped_winner_reasons["creative_processing_rejected"],
- 1
- );
- let json = response_json(conversion.response);
+ let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
+ .expect("should omit invalid winners and preserve valid slots");
+ let json = response_json(response);
let bids: Vec<&JsonValue> = json["seatbid"]
.as_array()
.expect("should include valid seatbids")
@@ -1523,30 +2005,16 @@ mod tests {
.collect();
assert_eq!(bids.len(), 2, "should omit only invalid winners");
- assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 4);
+ assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 2);
assert_eq!(
json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_render_source"],
2
);
- assert_eq!(
- json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_decoded_price"],
- 1
- );
- assert_eq!(
- json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"],
- 1
- );
let ordinary = bids
.iter()
.find(|bid| bid["impid"] == "ordinary")
.expect("should preserve ordinary winner");
- assert!(
- ordinary["adm"]
- .as_str()
- .is_some_and(|adm| adm.contains("Ad
")),
- "should preserve ordinary creative markup: {}",
- ordinary["adm"]
- );
+ assert_eq!(ordinary["adm"], "Ad
");
let renderer = bids
.iter()
.find(|bid| bid["impid"] == "renderer")
@@ -1561,11 +2029,38 @@ mod tests {
}
#[test]
- fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() {
- let settings = make_settings();
+ fn convert_to_openrtb_response_drops_creative_rejected_by_processing() {
+ let mut settings = make_settings();
+ settings.auction.sanitize_creatives = true;
+ settings.auction.rewrite_creatives = false;
+ let auction_request = make_auction_request();
+ let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
+ bid.creative = Some("".to_string());
+ let result = make_result(bid);
+
+ let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
+ .expect("should omit a creative rejected by configured processing");
+ let json = response_json(response);
+
+ assert!(
+ json["seatbid"].as_array().is_none_or(Vec::is_empty),
+ "should not serialize an empty adm"
+ );
+ assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1);
+ assert_eq!(
+ json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"],
+ 1,
+ "should report the exact processing rejection"
+ );
+ }
+
+ #[test]
+ fn convert_to_openrtb_response_rejects_multiple_render_sources() {
+ let mut settings = make_settings();
+ settings.auction.rewrite_creatives = false;
let auction_request = make_auction_request();
let mut bid = make_bid("div-gpt-top", "aps", Some(2.75));
- bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
+ bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "fictional-bid".to_string(),
@@ -1579,20 +2074,16 @@ mod tests {
let result = make_result(bid);
let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
- .expect("should prefer ordinary creative markup");
+ .expect("should reject an ambiguous render source");
let json = response_json(response);
- let bid = &json["seatbid"][0]["bid"][0];
-
- // Rewriting is on by default and a body-less fragment still receives the
- // creative runtime, so the markup is carried rather than returned verbatim.
- let adm = bid["adm"].as_str().expect("should serialize adm");
assert!(
- adm.contains("Ad
"),
- "should carry the creative markup: {adm}"
+ json["seatbid"].as_array().is_none_or(Vec::is_empty),
+ "should not serialize an ambiguous winner"
);
- assert!(
- bid.get("ext").is_none(),
- "should omit renderer extension when creative markup wins precedence"
+ assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1);
+ assert_eq!(
+ json["ext"]["orchestrator"]["dropped_winner_reasons"]["multiple_render_sources"], 1,
+ "should report the exact ambiguous-source reason"
);
}
@@ -1605,7 +2096,7 @@ mod tests {
bid.bid_id = Some("fictional-bid".to_string());
bid.ad_id = Some("fictional-ad".to_string());
bid.creative_id = Some("fictional-creative".to_string());
- bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
+ bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "fictional-bid".to_string(),
@@ -1672,6 +2163,11 @@ mod tests {
provider_responses: vec![],
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
};
@@ -1694,7 +2190,8 @@ mod tests {
#[test]
fn convert_to_openrtb_response_serializes_multiple_winning_bids() {
- let settings = make_settings();
+ let mut settings = make_settings();
+ settings.auction.rewrite_creatives = false;
let auction_request = make_auction_request();
let top_bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
let mut sidebar_bid = make_bid("div-gpt-sidebar", "rubicon", Some(1.25));
@@ -1712,6 +2209,11 @@ mod tests {
(top_bid.slot_id.clone(), top_bid),
(sidebar_bid.slot_id.clone(), sidebar_bid),
]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
};
@@ -1746,12 +2248,10 @@ mod tests {
"should preserve top slot impid"
);
assert_eq!(top_bid["price"], json!(2.75), "should preserve top price");
- assert!(
- top_bid["adm"]
- .as_str()
- .is_some_and(|adm| adm.contains("Ad
")),
- "should preserve top creative: {}",
- top_bid["adm"]
+ assert_eq!(
+ top_bid["adm"],
+ json!("Ad
"),
+ "should preserve top creative"
);
let sidebar_seatbid = seatbids
@@ -1779,12 +2279,10 @@ mod tests {
json!(1.25),
"should preserve sidebar price"
);
- assert!(
- sidebar_bid["adm"]
- .as_str()
- .is_some_and(|adm| adm.contains("Sidebar
")),
- "should preserve sidebar creative: {}",
- sidebar_bid["adm"]
+ assert_eq!(
+ sidebar_bid["adm"],
+ json!("Sidebar
"),
+ "should preserve sidebar creative"
);
assert_eq!(
json["ext"]["orchestrator"]["total_bids"],
@@ -1823,9 +2321,15 @@ mod tests {
assert!(conversion.delivery.delivered_winner_slots.is_empty());
assert_eq!(conversion.delivery.dropped_winner_count, 1);
assert_eq!(
- conversion.delivery.dropped_winner_reasons["no_decoded_price"], 1,
+ conversion.delivery.dropped_winner_reasons[&AuctionDropReason::InvalidPrice],
+ 1,
"should report the omitted malformed winner"
);
+ assert_eq!(
+ conversion.response.status(),
+ StatusCode::OK,
+ "should still return a successful partial auction response"
+ );
}
#[test]
@@ -1850,10 +2354,16 @@ mod tests {
#[cfg(test)]
mod convert_tests {
use super::*;
+ use crate::auction::types::{
+ AdmRenderSourceV1, AuctionDecisionSetV1, BidRenderSourceV1, BrowserAuctionBidV1,
+ BrowserAuctionProjectionV1, MAX_BROWSER_AUCTION_PROJECTION_BYTES, SlotAuctionDecisionV1,
+ };
use crate::consent::ConsentContext;
use crate::platform::test_support::noop_services;
use crate::test_support::tests::crate_test_settings_str;
use http::Method;
+ use serde_json::json;
+ use std::collections::BTreeMap;
fn make_settings() -> Settings {
Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings")
@@ -2026,4 +2536,273 @@ mod convert_tests {
"3-element banner size should return an error"
);
}
+
+ fn projection_candidate_id(index: usize) -> String {
+ format!("{index:012x}")
+ }
+
+ fn projection_reservation_id(index: usize) -> String {
+ format!("r1_{index:022x}")
+ }
+
+ fn projection_adm_bid(index: usize, slot: &str, adm: String) -> BrowserAuctionBidV1 {
+ BrowserAuctionBidV1 {
+ candidate_id: projection_candidate_id(index),
+ slot: slot.to_string(),
+ provider: "prebid".to_string(),
+ upstream_bid_id: format!("upstream-{index}"),
+ cpm: index as f64,
+ currency: "USD".to_string(),
+ targeting: BTreeMap::from([
+ ("z_key".to_string(), "last".to_string()),
+ ("a_key".to_string(), "first".to_string()),
+ ]),
+ renderer_reservation_id: Some(projection_reservation_id(index)),
+ render_source: BidRenderSourceV1::Adm(AdmRenderSourceV1 {
+ version: 1,
+ adm,
+ width: 300,
+ height: 250,
+ }),
+ }
+ }
+
+ fn projection_with_adm_lengths(lengths: &[usize]) -> BrowserAuctionProjectionV1 {
+ let results = lengths
+ .iter()
+ .enumerate()
+ .map(|(index, _)| SlotAuctionDecisionV1::Winner {
+ slot: format!("slot-{index}"),
+ candidate_id: projection_candidate_id(index),
+ })
+ .collect();
+ let bids = lengths
+ .iter()
+ .enumerate()
+ .map(|(index, length)| {
+ projection_adm_bid(index, &format!("slot-{index}"), "x".repeat(*length))
+ })
+ .rev()
+ .collect();
+ BrowserAuctionProjectionV1 {
+ version: 1,
+ auction: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results,
+ },
+ slots: Vec::new(),
+ bids,
+ }
+ }
+
+ #[test]
+ fn canonical_projection_orders_bids_and_targeting_by_contract() {
+ let input = projection_with_adm_lengths(&[1, 1]);
+ let mut permuted = input.clone();
+ permuted.bids.reverse();
+
+ let canonical =
+ canonicalize_browser_auction_projection_v1(input, "https://publisher.example")
+ .expect("valid projection should canonicalize");
+ let canonical_permuted =
+ canonicalize_browser_auction_projection_v1(permuted, "https://publisher.example")
+ .expect("response-order permutation should canonicalize");
+
+ assert!(!canonical.reduced_for_size);
+ assert_eq!(canonical.json, canonical_permuted.json);
+ assert_eq!(canonical.projection.bids[0].slot, "slot-0");
+ assert_eq!(canonical.projection.bids[1].slot, "slot-1");
+ let json = String::from_utf8(canonical.json).expect("canonical JSON should be UTF-8");
+ assert!(
+ json.find("\"a_key\"") < json.find("\"z_key\""),
+ "targeting keys should be lexically sorted"
+ );
+ assert!(
+ json.starts_with("{\"version\":1,\"auction\":{\"version\":1,\"auctionId\":"),
+ "top-level and decision-set fields should retain schema order: {json}"
+ );
+ }
+
+ #[test]
+ fn pbs_cache_wire_is_the_exact_thin_deny_unknown_carrier() {
+ let value = serde_json::json!({
+ "type": "pbs_cache",
+ "version": 1,
+ "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570",
+ "cacheHost": "cache.example:8443",
+ "cachePath": "/pbc/v1/cache/opaque%2Fpath",
+ "width": 0,
+ "height": u32::MAX
+ });
+ let source: BidRenderSourceV1 = serde_json::from_value(value.clone())
+ .expect("the final tagged union should admit the thin pbs_cache carrier");
+ assert_eq!(
+ serde_json::to_value(source).expect("cache carrier should serialize"),
+ value
+ );
+
+ let mut unknown = value;
+ unknown["fetchUrl"] = serde_json::Value::String(
+ "https://cache.example/pbc/v1/cache?uuid=not-authoritative".to_string(),
+ );
+ assert!(serde_json::from_value::(unknown).is_err());
+ }
+
+ #[test]
+ fn invalid_selected_winner_becomes_winner_not_renderable() {
+ let mut input = projection_with_adm_lengths(&[1]);
+ input.bids[0].renderer_reservation_id = Some("not-a-reservation".to_string());
+
+ let canonical =
+ canonicalize_browser_auction_projection_v1(input, "https://publisher.example")
+ .expect("selected projection failure should remain an explicit slot result");
+
+ assert!(canonical.projection.bids.is_empty());
+ assert_eq!(
+ canonical.projection.auction.results,
+ vec![SlotAuctionDecisionV1::Failed {
+ slot: "slot-0".to_string(),
+ reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable,
+ }]
+ );
+ }
+
+ #[test]
+ fn canonical_projection_enforces_exact_eight_mib_all_winner_reduction() {
+ let mut lengths = vec![512 * 1024; 15];
+ lengths.push(1);
+ let baseline = projection_with_adm_lengths(&lengths);
+ let baseline_len = serde_json::to_vec(&baseline)
+ .expect("typed baseline should serialize")
+ .len();
+ let exact_tail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baseline_len;
+ assert!(
+ exact_tail <= 512 * 1024,
+ "tail ADM should remain individually valid"
+ );
+
+ for (delta, should_reduce) in [(-1_isize, false), (0, false), (1, true)] {
+ lengths[15] = exact_tail
+ .checked_add_signed(delta)
+ .expect("positive exact tail");
+ let input = projection_with_adm_lengths(&lengths);
+ let canonical =
+ canonicalize_browser_auction_projection_v1(input, "https://publisher.example")
+ .expect("boundary projection should canonicalize or reduce");
+ assert_eq!(canonical.reduced_for_size, should_reduce, "delta {delta}");
+ assert!(canonical.json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES);
+ if should_reduce {
+ assert!(canonical.projection.bids.is_empty());
+ assert!(canonical.projection.auction.results.iter().all(|result| matches!(
+ result,
+ SlotAuctionDecisionV1::Failed {
+ reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable,
+ ..
+ }
+ )));
+ let wire: JsonValue = serde_json::from_slice(
+ &serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("reduced exact response should serialize"),
+ )
+ .expect("reduced exact response should be JSON");
+ assert_eq!(wire["seatbid"], json!([]));
+ } else {
+ assert_eq!(
+ canonical.json.len(),
+ MAX_BROWSER_AUCTION_PROJECTION_BYTES
+ .checked_add_signed(delta)
+ .expect("boundary size should remain positive")
+ );
+ if delta == 0 {
+ let wire = serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("exact-boundary response should serialize");
+ assert!(
+ wire.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES,
+ "exact response should not exceed the admitted projection cap"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn exact_openrtb_serializer_uses_reservation_and_trusted_server_join_only() {
+ let canonical = canonicalize_browser_auction_projection_v1(
+ projection_with_adm_lengths(&[7]),
+ "https://publisher.example",
+ )
+ .expect("projection should canonicalize");
+
+ let json: JsonValue = serde_json::from_slice(
+ &serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("exact response should serialize"),
+ )
+ .expect("exact response should be JSON");
+
+ let bid = &json["seatbid"][0]["bid"][0];
+ assert_eq!(bid["id"], projection_reservation_id(0));
+ assert_eq!(bid["impid"], "slot-0");
+ assert!(
+ bid.get("adm").is_none(),
+ "tagged render_source should be the sole browser authority"
+ );
+ assert_eq!(json["cur"], "USD");
+ assert_eq!(
+ bid["ext"]["trusted_server"],
+ json!({
+ "candidate_id": projection_candidate_id(0),
+ "slot_id": "slot-0",
+ "render_source": {
+ "type": "adm",
+ "version": 1,
+ "adm": "xxxxxxx",
+ "width": 300,
+ "height": 250,
+ }
+ })
+ );
+ assert_eq!(
+ json["ext"]["trusted_server"]["slot_results"],
+ serde_json::to_value(&canonical.projection.auction)
+ .expect("decision set should serialize")
+ );
+ }
+
+ #[test]
+ fn exact_openrtb_serializer_carries_identity_generation_failure_without_a_bid() {
+ let canonical = canonicalize_browser_auction_projection_v1(
+ BrowserAuctionProjectionV1 {
+ version: 1,
+ auction: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-identity-failure".to_string(),
+ results: vec![SlotAuctionDecisionV1::Failed {
+ slot: "slot-0".to_string(),
+ reason: crate::auction::types::AuctionSlotFailureReason::IdentityGenerationFailed,
+ }],
+ },
+ slots: Vec::new(),
+ bids: Vec::new(),
+ },
+ "https://publisher.example",
+ )
+ .expect("identity failure decision should canonicalize");
+
+ let json: JsonValue = serde_json::from_slice(
+ &serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("identity failure response should serialize"),
+ )
+ .expect("identity failure response should be JSON");
+
+ assert_eq!(json["seatbid"], json!([]));
+ assert_eq!(
+ json["ext"]["trusted_server"]["slot_results"]["results"][0],
+ json!({
+ "slot": "slot-0",
+ "outcome": "failed",
+ "reason": "identity_generation_failed",
+ })
+ );
+ }
}
diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs
index 1552ee4fd..cd2354507 100644
--- a/crates/trusted-server-core/src/auction/orchestrator.rs
+++ b/crates/trusted-server-core/src/auction/orchestrator.rs
@@ -12,9 +12,25 @@ use crate::error::TrustedServerError;
use crate::platform::{PlatformPendingRequest, RuntimeServices};
use super::config::AuctionConfig;
-use super::provider::{AuctionProvider, ProviderParseState, ProviderRequestOutcome};
+use super::provider::{
+ AuctionProvider, ProviderParseState, ProviderRequestOutcome, ProviderSlotDisposition,
+ ProviderSlotOutcome,
+};
use super::telemetry::AbandonedProviderCall;
-use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus};
+use super::types::{
+ AuctionContext, AuctionDecisionSetV1, AuctionDropReason, AuctionIdentityGenerator,
+ AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidStatus,
+ SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, mint_response_unique_base64url_identity,
+};
+
+const CANDIDATE_ID_BYTES: usize = 9;
+const CANDIDATE_ID_COLLISION_RETRIES: usize = 8;
+const MAX_UPSTREAM_BID_ID_BYTES: usize = 64;
+
+struct NormalizedProviderResponses {
+ outcomes: Vec,
+ candidates: HashMap,
+}
/// In-flight auction requests dispatched to SSP backends.
///
@@ -43,23 +59,6 @@ struct ProviderLaunchState {
parse_state: Option,
}
-/// Outcome of attempting to dispatch split-phase auction provider requests.
-pub enum DispatchAuctionOutcome {
- /// No provider request was started and no provider failure was observed.
- NotStarted,
- /// No provider request could be launched, but launch failures were observed.
- DispatchFailed {
- /// Original auction request.
- request: AuctionRequest,
- /// Provider launch-failure responses.
- provider_responses: Vec,
- /// Elapsed dispatch time.
- elapsed_ms: u64,
- },
- /// One or more providers produced an immediate response or started a request.
- Dispatched(DispatchedAuction),
-}
-
impl DispatchedAuction {
/// Consume the dispatch token without collecting provider responses.
#[must_use]
@@ -169,6 +168,23 @@ fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> Auct
.with_metadata("message", serde_json::json!("Provider request timed out"))
}
+fn canonical_provider_response(
+ expected_provider: &str,
+ response: AuctionResponse,
+) -> AuctionResponse {
+ if response.provider == expected_provider {
+ response
+ } else {
+ log::warn!(
+ "Provider '{}' returned response identity '{}'; rejecting mismatched response",
+ expected_provider,
+ response.provider
+ );
+ AuctionResponse::error(expected_provider, response.response_time_ms)
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse)
+ }
+}
+
/// Compute the remaining time budget from a deadline.
///
/// Returns the number of milliseconds left before `timeout_ms` is exceeded,
@@ -192,6 +208,7 @@ fn snapshot_context_request(request: &Request) -> Request {
pub struct AuctionOrchestrator {
config: AuctionConfig,
providers: HashMap>,
+ identity_generator: Arc,
}
impl AuctionOrchestrator {
@@ -201,6 +218,19 @@ impl AuctionOrchestrator {
Self {
config,
providers: HashMap::new(),
+ identity_generator: Arc::new(SystemAuctionIdentityGenerator),
+ }
+ }
+
+ #[cfg(test)]
+ fn with_identity_generator(
+ config: AuctionConfig,
+ identity_generator: Arc,
+ ) -> Self {
+ Self {
+ config,
+ providers: HashMap::new(),
+ identity_generator,
}
}
@@ -264,6 +294,366 @@ impl AuctionOrchestrator {
Ok(())
}
+ fn provider_is_eligible_for_slot(
+ &self,
+ provider_name: &str,
+ slot: &super::types::AdSlot,
+ ) -> bool {
+ self.providers.get(provider_name).is_some_and(|provider| {
+ provider.is_enabled()
+ && slot
+ .formats
+ .iter()
+ .any(|format| provider.supports_media_type(&format.media_type))
+ })
+ }
+
+ fn eligible_slot_ids(&self, provider_name: &str, request: &AuctionRequest) -> HashSet {
+ request
+ .slots
+ .iter()
+ .filter(|slot| self.provider_is_eligible_for_slot(provider_name, slot))
+ .map(|slot| slot.id.clone())
+ .collect()
+ }
+
+ fn valid_upstream_bid_id(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= MAX_UPSTREAM_BID_ID_BYTES
+ && !value.bytes().any(|byte| byte <= 0x1f || byte == 0x7f)
+ }
+
+ fn mint_candidate_id(&self, issued: &mut HashSet) -> Option {
+ let candidate_id = mint_response_unique_base64url_identity(
+ self.identity_generator.as_ref(),
+ issued,
+ "",
+ CANDIDATE_ID_BYTES,
+ CANDIDATE_ID_COLLISION_RETRIES,
+ )?;
+ debug_assert_eq!(candidate_id.len(), 12);
+ Some(candidate_id)
+ }
+
+ fn response_failure_reason(response: &AuctionResponse) -> Option {
+ if response.status == BidStatus::Error || response.status == BidStatus::Pending {
+ return match response
+ .metadata
+ .get("error_type")
+ .and_then(serde_json::Value::as_str)
+ {
+ Some(ERROR_TYPE_TIMEOUT) => Some(AuctionSlotFailureReason::ProviderTimeout),
+ Some(ERROR_TYPE_PARSE_RESPONSE) => {
+ Some(AuctionSlotFailureReason::InvalidProviderResponse)
+ }
+ _ => {
+ let invalid = response
+ .metadata
+ .get("drop_reasons")
+ .and_then(serde_json::Value::as_object)
+ .is_some_and(|reasons| reasons.contains_key("invalid_provider_response"));
+ Some(if invalid {
+ AuctionSlotFailureReason::InvalidProviderResponse
+ } else {
+ AuctionSlotFailureReason::ProviderError
+ })
+ }
+ };
+ }
+
+ None
+ }
+
+ fn normalize_provider_responses(
+ &self,
+ request: &AuctionRequest,
+ responses: &mut [AuctionResponse],
+ ) -> NormalizedProviderResponses {
+ let requested_slots: HashMap<&str, &super::types::AdSlot> = request
+ .slots
+ .iter()
+ .map(|slot| (slot.id.as_str(), slot))
+ .collect();
+ let mut issued_candidate_ids = HashSet::new();
+ let mut candidates = HashMap::new();
+ let mut outcomes = Vec::new();
+
+ for response in responses {
+ let eligible_slots = self.eligible_slot_ids(&response.provider, request);
+ let response_failure = Self::response_failure_reason(response);
+ let mut upstream_counts = HashMap::::new();
+ for bid in &response.bids {
+ if let Some(upstream_id) = bid.bid_id.as_deref()
+ && Self::valid_upstream_bid_id(upstream_id)
+ {
+ *upstream_counts.entry(upstream_id.to_string()).or_default() += 1;
+ }
+ }
+
+ let mut invalid_slots = response
+ .metadata
+ .get("invalid_slots")
+ .and_then(serde_json::Value::as_object)
+ .map(|slots| {
+ slots
+ .iter()
+ .filter_map(|(slot, reason)| {
+ (reason.as_str() == Some("invalid_provider_response")).then_some((
+ slot.clone(),
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ ))
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+ let mut global_invalid = response
+ .metadata
+ .get("global_invalid_provider_response")
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false);
+ let mut accepted = Vec::new();
+ for mut bid in core::mem::take(&mut response.bids) {
+ let requested_slot = requested_slots.get(bid.slot_id.as_str()).copied();
+ let slot_is_eligible = eligible_slots.contains(&bid.slot_id);
+ let dimensions_match = requested_slot.is_some_and(|slot| {
+ slot.formats.iter().any(|format| {
+ format.width == bid.width
+ && format.height == bid.height
+ && self
+ .providers
+ .get(&response.provider)
+ .is_some_and(|provider| {
+ provider.supports_media_type(&format.media_type)
+ })
+ })
+ });
+ let upstream_id = bid.bid_id.as_deref();
+ let upstream_is_valid = upstream_id.is_some_and(Self::valid_upstream_bid_id);
+ let upstream_is_unique = upstream_id.is_some_and(|upstream_id| {
+ upstream_counts.get(upstream_id).copied() == Some(1)
+ });
+ let bid_is_valid = response.status == BidStatus::Success
+ && slot_is_eligible
+ && dimensions_match
+ && upstream_is_valid
+ && upstream_is_unique
+ && bid.currency == "USD"
+ && bid
+ .price
+ .is_some_and(|price| price.is_finite() && price >= 0.0);
+
+ if !bid_is_valid {
+ if requested_slot.is_some() {
+ invalid_slots
+ .entry(bid.slot_id.clone())
+ .or_insert(AuctionSlotFailureReason::InvalidProviderResponse);
+ } else {
+ global_invalid = true;
+ }
+ continue;
+ }
+
+ let Some(candidate_id) = self.mint_candidate_id(&mut issued_candidate_ids) else {
+ invalid_slots
+ .insert(bid.slot_id.clone(), AuctionSlotFailureReason::InternalError);
+ continue;
+ };
+ bid.candidate_id = Some(candidate_id.clone());
+ bid.candidate_provider = Some(response.provider.clone());
+ bid.renderer_reservation_id = None;
+ candidates.insert(candidate_id, bid.clone());
+ accepted.push(bid);
+ }
+ let internally_failed_slots: HashSet<&str> = invalid_slots
+ .iter()
+ .filter_map(|(slot, reason)| {
+ (*reason == AuctionSlotFailureReason::InternalError).then_some(slot.as_str())
+ })
+ .collect();
+ if !internally_failed_slots.is_empty() {
+ accepted.retain(|bid| !internally_failed_slots.contains(bid.slot_id.as_str()));
+ candidates.retain(|_, bid| {
+ bid.candidate_provider.as_deref() != Some(response.provider.as_str())
+ || !internally_failed_slots.contains(bid.slot_id.as_str())
+ });
+ }
+ response.bids = accepted;
+
+ for slot in &request.slots {
+ if !eligible_slots.contains(&slot.id) {
+ continue;
+ }
+ let slot_candidates: Vec = response
+ .bids
+ .iter()
+ .filter(|bid| bid.slot_id == slot.id)
+ .cloned()
+ .collect();
+ let disposition = if !slot_candidates.is_empty() {
+ ProviderSlotDisposition::Candidates(slot_candidates)
+ } else if let Some(reason) = invalid_slots.get(&slot.id).copied() {
+ ProviderSlotDisposition::Failed(reason)
+ } else if global_invalid {
+ ProviderSlotDisposition::Failed(
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ )
+ } else if let Some(reason) = response_failure {
+ ProviderSlotDisposition::Failed(reason)
+ } else {
+ ProviderSlotDisposition::NoBid
+ };
+ outcomes.push(ProviderSlotOutcome {
+ provider: response.provider.clone(),
+ slot: slot.id.clone(),
+ disposition,
+ });
+ }
+ }
+
+ NormalizedProviderResponses {
+ outcomes,
+ candidates,
+ }
+ }
+
+ fn build_decision_set(
+ &self,
+ request: &AuctionRequest,
+ outcomes: &[ProviderSlotOutcome],
+ winning_bids: &HashMap,
+ mediation_failed: bool,
+ ) -> AuctionDecisionSetV1 {
+ let results = request
+ .slots
+ .iter()
+ .map(|slot| {
+ if let Some(winner) = winning_bids.get(&slot.id) {
+ return winner.candidate_id.as_ref().map_or_else(
+ || SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason: AuctionSlotFailureReason::WinnerNotRenderable,
+ },
+ |candidate_id| SlotAuctionDecisionV1::Winner {
+ slot: slot.id.clone(),
+ candidate_id: candidate_id.clone(),
+ },
+ );
+ }
+
+ let eligible_provider_count = self
+ .config
+ .provider_names()
+ .iter()
+ .filter(|provider| self.provider_is_eligible_for_slot(provider, slot))
+ .count();
+ if eligible_provider_count == 0 {
+ return SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason: AuctionSlotFailureReason::SlotNotEligible,
+ };
+ }
+
+ let mut failures: Vec = outcomes
+ .iter()
+ .filter(|outcome| outcome.slot == slot.id)
+ .filter_map(|outcome| match outcome.disposition {
+ ProviderSlotDisposition::Failed(reason) => Some(reason),
+ ProviderSlotDisposition::Candidates(_) | ProviderSlotDisposition::NoBid => {
+ None
+ }
+ })
+ .collect();
+ if mediation_failed {
+ failures.push(AuctionSlotFailureReason::MediationFailed);
+ }
+ failures.sort_by_key(|reason| reason.priority());
+ failures.first().copied().map_or_else(
+ || SlotAuctionDecisionV1::NoBid {
+ slot: slot.id.clone(),
+ },
+ |reason| SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason,
+ },
+ )
+ })
+ .collect();
+
+ AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results,
+ }
+ }
+
+ fn resolve_mediator_candidates(
+ mediator_response: AuctionResponse,
+ candidates: &HashMap,
+ ) -> Result {
+ if mediator_response.status == BidStatus::Error
+ || mediator_response.status == BidStatus::Pending
+ {
+ return Err(());
+ }
+
+ let mut seen = HashSet::new();
+ let mut seen_slots = HashSet::new();
+ let mut resolved = Vec::with_capacity(mediator_response.bids.len());
+ for selection in &mediator_response.bids {
+ let Some(candidate_id) = selection.candidate_id.as_deref() else {
+ return Err(());
+ };
+ if !seen.insert(candidate_id.to_string()) {
+ return Err(());
+ }
+ let Some(source) = candidates.get(candidate_id) else {
+ return Err(());
+ };
+ let Some(selected_price) = selection
+ .price
+ .filter(|price| price.is_finite() && *price >= 0.0)
+ else {
+ return Err(());
+ };
+ let source_authority_matches = selection.slot_id == source.slot_id
+ && selection.candidate_provider == source.candidate_provider
+ && selection.currency == source.currency
+ && selection.creative == source.creative
+ && selection.adomain == source.adomain
+ && selection.bidder == source.bidder
+ && selection.width == source.width
+ && selection.height == source.height
+ && selection.nurl == source.nurl
+ && selection.burl == source.burl
+ && selection.bid_id == source.bid_id
+ && selection.ad_id == source.ad_id
+ && selection.creative_id == source.creative_id
+ && selection.renderer == source.renderer
+ && selection.cache_id == source.cache_id
+ && selection.cache_host == source.cache_host
+ && selection.cache_path == source.cache_path;
+ if !seen_slots.insert(source.slot_id.as_str()) || !source_authority_matches {
+ return Err(());
+ }
+
+ let mut restored = source.clone();
+ restored.price = Some(selected_price);
+ resolved.push(restored);
+ }
+
+ Ok(AuctionResponse {
+ provider: mediator_response.provider,
+ status: if resolved.is_empty() {
+ BidStatus::NoBid
+ } else {
+ BidStatus::Success
+ },
+ bids: resolved,
+ response_time_ms: mediator_response.response_time_ms,
+ metadata: mediator_response.metadata,
+ })
+ }
+
/// Execute an auction using the auto-detected strategy.
///
/// Strategy is determined by mediator configuration:
@@ -281,6 +671,20 @@ impl AuctionOrchestrator {
) -> Result> {
let start_time = Instant::now();
+ if !self.config.enabled {
+ return Ok(OrchestrationResult {
+ provider_responses: Vec::new(),
+ mediator_response: None,
+ winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1::failed(
+ request,
+ AuctionSlotFailureReason::AuctionDisabled,
+ ),
+ total_time_ms: 0,
+ metadata: HashMap::new(),
+ });
+ }
+
// Auto-detect strategy based on mediator configuration
let (strategy_name, result) = if self.config.has_mediator() {
(
@@ -317,119 +721,125 @@ impl AuctionOrchestrator {
context: &AuctionContext<'_>,
) -> Result> {
let mediation_start = Instant::now();
- let provider_responses = self.run_providers_parallel(request, context).await?;
+ let mut provider_responses = self.run_providers_parallel(request, context).await?;
+ let normalized = self.normalize_provider_responses(request, &mut provider_responses);
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)?;
-
- log::info!(
- "Sending {} provider responses to mediator: {}",
- provider_responses.len(),
- mediator.provider_name()
- );
-
- // Give the mediator 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
- .services
- .backend()
- .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms());
-
- if mediator_timeout == 0 {
- log::warn!("Auction timeout exhausted during bidding phase; skipping mediator");
- let winning = self.select_winning_bids(&provider_responses, &floor_prices);
- return Ok(OrchestrationResult {
- provider_responses,
- mediator_response: None,
- winning_bids: winning,
- total_time_ms: 0,
- metadata: HashMap::new(),
- });
- }
-
- let mediator_context = AuctionContext {
- settings: context.settings,
- request: context.request,
- timeout_ms: mediator_timeout,
- provider_responses: Some(&provider_responses),
- services: context.services,
- };
-
- let start_time = Instant::now();
- let mediator_resp = match mediator
- .request_bids(request, &mediator_context)
- .await
- .change_context(TrustedServerError::Auction {
- message: format!("Mediator {} failed to launch", mediator.provider_name()),
- })? {
- ProviderRequestOutcome::Immediate(response) => response,
- ProviderRequestOutcome::Pending {
- request: pending,
- parse_state,
- } => {
- let platform_resp = mediator_context
- .services
- .http_client()
- .wait(pending)
- .await
- .change_context(TrustedServerError::Auction {
- message: format!(
- "Mediator {} request failed",
+ let mut mediation_failed = false;
+ let mut mediator_response = None;
+ let mut winning_bids = None;
+
+ if let Some(mediator_name) = &self.config.mediator {
+ if let Some(mediator) = self.providers.get(mediator_name) {
+ log::info!(
+ "Sending {} provider responses to mediator: {}",
+ provider_responses.len(),
+ mediator.provider_name()
+ );
+ let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms);
+ if remaining_ms == 0 {
+ log::warn!("Auction timeout exhausted during bidding phase; skipping mediator");
+ mediation_failed = true;
+ } else {
+ let mediator_context = AuctionContext {
+ settings: context.settings,
+ request: context.request,
+ timeout_ms: context
+ .services
+ .backend()
+ .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()),
+ provider_responses: Some(&provider_responses),
+ services: context.services,
+ };
+ let start_time = Instant::now();
+ let raw_response = match mediator.request_bids(request, &mediator_context).await
+ {
+ Ok(ProviderRequestOutcome::Immediate(response)) => Some(response),
+ Ok(ProviderRequestOutcome::Pending {
+ request: pending,
+ parse_state,
+ }) => match mediator_context.services.http_client().wait(pending).await {
+ Ok(platform_response) => mediator
+ .parse_response_with_context_and_state(
+ platform_response,
+ start_time.elapsed().as_millis() as u64,
+ request,
+ &mediator_context,
+ parse_state.as_deref(),
+ )
+ .await
+ .inspect_err(|error| {
+ log::warn!(
+ "Mediator '{}' parse failed: {error:?}",
+ mediator.provider_name()
+ );
+ })
+ .ok(),
+ Err(error) => {
+ log::warn!(
+ "Mediator '{}' request failed: {error:?}",
+ mediator.provider_name()
+ );
+ None
+ }
+ },
+ Err(error) => {
+ log::warn!(
+ "Mediator '{}' failed to launch: {error:?}",
mediator.provider_name()
- ),
- })?;
-
- mediator
- .parse_response_with_context_and_state(
- platform_resp,
- start_time.elapsed().as_millis() as u64,
- request,
- &mediator_context,
- parse_state.as_deref(),
- )
- .await
- .change_context(TrustedServerError::Auction {
- message: format!("Mediator {} parse failed", mediator.provider_name()),
- })?
- }
- };
+ );
+ None
+ }
+ };
- // Extract only mediator bids with comparable numeric prices.
- let winning = mediator_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(),
- bid.slot_id
- );
- None
+ if let Some(raw_response) = raw_response {
+ mediator_response = Some(raw_response.clone());
+ match Self::resolve_mediator_candidates(
+ raw_response,
+ &normalized.candidates,
+ ) {
+ Ok(resolved) => {
+ let selected = resolved
+ .bids
+ .iter()
+ .map(|bid| (bid.slot_id.clone(), bid.clone()))
+ .collect();
+ winning_bids =
+ Some(self.apply_floor_prices(selected, &floor_prices));
+ mediator_response = Some(resolved);
+ }
+ Err(()) => {
+ log::warn!(
+ "Mediator '{}' returned invalid candidate provenance",
+ mediator.provider_name()
+ );
+ mediation_failed = true;
+ }
+ }
} else {
- Some((bid.slot_id.clone(), bid.clone()))
+ mediation_failed = true;
}
- })
- .collect();
+ }
+ } else {
+ log::warn!("Mediator '{}' not registered", mediator_name);
+ mediation_failed = true;
+ }
+ }
- (
- Some(mediator_resp),
- self.apply_floor_prices(winning, &floor_prices),
- )
- } else {
- // No mediator - select best bid per slot from bidder responses
- let winning = self.select_winning_bids(&provider_responses, &floor_prices);
- (None, winning)
- };
+ let winning_bids = winning_bids
+ .unwrap_or_else(|| self.select_winning_bids(&provider_responses, &floor_prices));
+ let decision_set = self.build_decision_set(
+ request,
+ &normalized.outcomes,
+ &winning_bids,
+ mediation_failed,
+ );
Ok(OrchestrationResult {
provider_responses,
mediator_response,
winning_bids,
+ decision_set,
total_time_ms: 0, // Will be set by caller
metadata: HashMap::new(),
})
@@ -441,14 +851,18 @@ impl AuctionOrchestrator {
request: &AuctionRequest,
context: &AuctionContext<'_>,
) -> Result> {
- let provider_responses = self.run_providers_parallel(request, context).await?;
+ let mut provider_responses = self.run_providers_parallel(request, context).await?;
+ let normalized = self.normalize_provider_responses(request, &mut provider_responses);
let floor_prices = self.floor_prices_by_slot(request);
let winning_bids = self.select_winning_bids(&provider_responses, &floor_prices);
+ let decision_set =
+ self.build_decision_set(request, &normalized.outcomes, &winning_bids, false);
Ok(OrchestrationResult {
provider_responses,
mediator_response: None,
winning_bids,
+ decision_set,
total_time_ms: 0,
metadata: HashMap::new(),
})
@@ -466,9 +880,7 @@ impl AuctionOrchestrator {
let provider_names = self.config.provider_names();
if provider_names.is_empty() {
- return Err(Report::new(TrustedServerError::Auction {
- message: "No providers configured".to_string(),
- }));
+ return Ok(Vec::new());
}
// Reject multi-provider fan-out before any request launches when the
@@ -477,14 +889,14 @@ impl AuctionOrchestrator {
// blow the auction budget before a later `select` could reject it.
if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout()
{
- return Err(Report::new(TrustedServerError::Auction {
- message: format!(
- "{} auction providers configured, but this platform's HTTP \
- client executes requests sequentially — configure a single \
- provider, or use an adapter with concurrent fan-out support",
- provider_names.len(),
- ),
- }));
+ log::warn!(
+ "{} auction providers configured, but this platform's HTTP client executes requests sequentially",
+ provider_names.len(),
+ );
+ return Ok(provider_names
+ .iter()
+ .map(|provider_name| provider_launch_failed_response(provider_name, 0))
+ .collect());
}
log::info!(
@@ -500,8 +912,6 @@ impl AuctionOrchestrator {
let mut backend_to_provider: HashMap = HashMap::new();
let mut pending_requests: Vec = Vec::new();
let mut responses = Vec::new();
- let mut immediate_response_count = 0usize;
-
for provider_name in provider_names {
let provider = match self.providers.get(provider_name) {
Some(p) => p,
@@ -532,20 +942,23 @@ impl AuctionOrchestrator {
// budget skips every provider, including one that might respond immediately.
if effective_timeout == 0 {
log::warn!("Auction timeout exhausted before launching provider request; skipping");
+ responses.push(provider_timeout_response(provider.provider_name(), 0));
continue;
}
- // Immediate providers have no backend name and must remain eligible
- // to return a synchronous result. Pending providers are still
- // guarded before dispatch when their name can be predicted.
- let predicted_backend_name = provider.backend_name(context.services, effective_timeout);
- if let Some(backend_name) = predicted_backend_name.as_ref()
- && backend_to_provider.contains_key(backend_name)
+ // Pre-launch guard: `request_bids` fires the outbound send, and
+ // discarding the returned pending handle afterwards does not retract
+ // it. If another provider this auction already claimed the predicted
+ // backend name, skip *before* dispatching so a duplicate never hits
+ // the wire. The post-launch check below stays as a defense for a
+ // provider that resolves to an unexpected name.
+ if let Some(predicted) = provider.backend_name(context.services, effective_timeout)
+ && backend_to_provider.contains_key(&predicted)
{
log::warn!(
"Provider '{}' predicted backend name '{}' already belongs to another provider; skipping launch",
provider.provider_name(),
- backend_name,
+ predicted,
);
responses.push(provider_launch_failed_response(provider.provider_name(), 0));
continue;
@@ -572,14 +985,13 @@ impl AuctionOrchestrator {
parse_state,
}) => {
let request_backend_name = pending.backend_name().map(str::to_string).or_else(|| {
- if let Some(backend_name) = predicted_backend_name.as_ref() {
+ provider.backend_name(context.services, effective_timeout).inspect(|name| {
log::warn!(
"Provider '{}' pending request returned no backend name; using predicted name '{}'",
provider.provider_name(),
- backend_name,
+ name,
);
- }
- predicted_backend_name.clone()
+ })
});
let Some(request_backend_name) = request_backend_name else {
log::warn!(
@@ -592,6 +1004,21 @@ impl AuctionOrchestrator {
));
continue;
};
+ // Post-launch defense: a resolved backend name already
+ // claimed by another provider would misattribute that
+ // provider's response, so fail this launch attributably
+ // instead of overwriting the correlation entry.
+ if backend_to_provider.contains_key(&request_backend_name) {
+ log::warn!(
+ "Provider '{}' pending request has no backend name; response cannot be correlated",
+ provider.provider_name()
+ );
+ responses.push(provider_launch_failed_response(
+ provider.provider_name(),
+ start_time.elapsed().as_millis() as u64,
+ ));
+ continue;
+ };
if backend_to_provider.contains_key(&request_backend_name) {
log::warn!(
"Provider '{}' resolved backend name '{}' already belongs to another provider; skipping launch",
@@ -621,12 +1048,14 @@ impl AuctionOrchestrator {
);
}
Ok(ProviderRequestOutcome::Immediate(response)) => {
- immediate_response_count += 1;
log::debug!(
"Provider '{}' completed without an upstream request",
provider.provider_name()
);
- responses.push(response);
+ responses.push(canonical_provider_response(
+ provider.provider_name(),
+ response,
+ ));
}
Err(e) => {
let response_time_ms = start_time.elapsed().as_millis() as u64;
@@ -644,18 +1073,7 @@ impl AuctionOrchestrator {
}
if pending_requests.is_empty() {
- // An immediate response (for example, an APS-only Prebid no-bid) is
- // a completed provider outcome. Launch failures alone remain a
- // terminal auction error rather than being converted to a 200 no-bid.
- if immediate_response_count > 0 {
- return Ok(responses);
- }
- return Err(Report::new(TrustedServerError::Auction {
- message: format!(
- "All {} configured provider(s) skipped or failed to launch",
- provider_names.len()
- ),
- }));
+ return Ok(responses);
}
let deadline = Duration::from_millis(u64::from(context.timeout_ms));
@@ -672,7 +1090,7 @@ impl AuctionOrchestrator {
// some adapters, buffers the selected response body before returning.
// Backend first-byte and between-bytes timeouts are capped to the
// remaining auction budget in Phase 1. They are transport timers, not
- // absolute wall-clock limits, so connection setup and byte-trickling
+ // absolute wall-clock limits, so connection setup and byte trickling
// remain bounded operational risks rather than strict deadline proof.
let mut remaining = pending_requests;
@@ -729,7 +1147,10 @@ impl AuctionOrchestrator {
auction_response.status,
auction_response.response_time_ms
);
- responses.push(auction_response);
+ responses.push(canonical_provider_response(
+ &state.provider_name,
+ auction_response,
+ ));
}
Err(e) => {
// lgtm[rust/cleartext-logging]
@@ -837,9 +1258,20 @@ impl AuctionOrchestrator {
};
let should_replace = match winning_bids.get(&bid.slot_id) {
- Some(current_winner) => current_winner
- .price
- .is_none_or(|current_price| bid_price > current_price),
+ Some(current_winner) => current_winner.price.is_none_or(|current_price| {
+ bid_price > current_price
+ || (bid_price == current_price
+ && (
+ bid.candidate_provider.as_deref().unwrap_or(&bid.bidder),
+ bid.bid_id.as_deref().unwrap_or_default(),
+ ) < (
+ current_winner
+ .candidate_provider
+ .as_deref()
+ .unwrap_or(¤t_winner.bidder),
+ current_winner.bid_id.as_deref().unwrap_or_default(),
+ ))
+ }),
None => true,
};
@@ -906,23 +1338,6 @@ impl AuctionOrchestrator {
.collect()
}
- /// Get a provider by name.
- fn get_provider(
- &self,
- name: &str,
- ) -> Result<&Arc, Report> {
- self.providers.get(name).ok_or_else(|| {
- log::warn!(
- "Provider '{}' configured but not registered. Available providers: {:?}",
- name,
- self.providers.keys().collect::>()
- );
- Report::new(TrustedServerError::Auction {
- message: format!("Provider '{}' not registered", name),
- })
- })
- }
-
/// Dispatch SSP bid requests without blocking WASM.
///
/// Calls each enabled provider's [`AuctionProvider::request_bids`] (which
@@ -930,28 +1345,31 @@ impl AuctionOrchestrator {
/// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips
/// while WASM continues to `pending_origin.wait()`.
///
- /// Returns [`DispatchAuctionOutcome::NotStarted`] when no providers are configured or
- /// all providers are disabled / over budget. Returns
- /// [`DispatchAuctionOutcome::DispatchFailed`] when provider launch attempts
- /// happened but none could be started.
+ /// The token is returned even when no transport starts. Collection then
+ /// routes zero-budget, launch-failure, disabled, and unconfigured-provider
+ /// cases through the same exhaustive terminal decision builder as ordinary
+ /// responses instead of silently dropping their slot outcomes.
#[must_use]
pub async fn dispatch_auction(
&self,
request: &AuctionRequest,
context: &AuctionContext<'_>,
- ) -> DispatchAuctionOutcome {
+ ) -> DispatchedAuction {
let provider_names = self.config.provider_names();
- if provider_names.is_empty() {
- return DispatchAuctionOutcome::NotStarted;
- }
+ let auction_start = Instant::now();
+ let mut backend_to_provider: HashMap = HashMap::new();
+ let mut pending_requests: Vec = Vec::new();
+ let mut completed_responses: Vec = Vec::new();
+ let mut immediate_response_count = 0usize;
// Mirror run_providers_parallel: reject multi-provider fan-out before
// any request launches when the platform executes `send_async` eagerly
// (e.g. Cloudflare Workers, Spin). Sequential execution would accrue
// the sum of provider latencies before the origin fetch and then fail
// collection with empty bids.
- if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout()
- {
+ let fanout_supported = provider_names.len() <= 1
+ || context.services.http_client().supports_concurrent_fanout();
+ if !fanout_supported {
log::warn!(
"{} auction providers configured, but this platform's HTTP client \
executes requests sequentially — skipping initial-page auction \
@@ -959,16 +1377,14 @@ impl AuctionOrchestrator {
concurrent fan-out support",
provider_names.len(),
);
- return DispatchAuctionOutcome::NotStarted;
+ completed_responses.extend(
+ provider_names
+ .iter()
+ .map(|provider| provider_launch_failed_response(provider, 0)),
+ );
}
- let auction_start = Instant::now();
- let mut backend_to_provider: HashMap = HashMap::new();
- let mut pending_requests: Vec = Vec::new();
- let mut completed_responses: Vec = Vec::new();
- let mut immediate_response_count = 0usize;
-
- for provider_name in provider_names {
+ for provider_name in provider_names.iter().filter(|_| fanout_supported) {
let provider = match self.providers.get(provider_name) {
Some(p) => p,
None => {
@@ -1001,20 +1417,24 @@ impl AuctionOrchestrator {
context.timeout_ms,
provider.provider_name()
);
+ completed_responses.push(provider_timeout_response(
+ provider.provider_name(),
+ auction_start.elapsed().as_millis() as u64,
+ ));
continue;
}
- // Do not require a backend name before dispatch: an immediate
- // provider intentionally has none. Guard predicted names when
- // available; pending requests without either name fail below.
- let predicted_backend_name = provider.backend_name(context.services, effective_timeout);
- if let Some(backend_name) = predicted_backend_name.as_ref()
- && backend_to_provider.contains_key(backend_name)
+ // Pre-launch guard: skip before `request_bids` fires the outbound
+ // send when another provider this auction already claimed the
+ // predicted backend name (see the parallel path). Dropping the
+ // pending handle afterwards would not retract the request.
+ if let Some(predicted) = provider.backend_name(context.services, effective_timeout)
+ && backend_to_provider.contains_key(&predicted)
{
log::warn!(
"Provider '{}' predicted backend name '{}' already belongs to another provider; skipping dispatch",
provider.provider_name(),
- backend_name,
+ predicted,
);
completed_responses
.push(provider_launch_failed_response(provider.provider_name(), 0));
@@ -1035,16 +1455,10 @@ impl AuctionOrchestrator {
request: pending,
parse_state,
}) => {
- let backend_name = pending.backend_name().map(str::to_string).or_else(|| {
- if let Some(backend_name) = predicted_backend_name.as_ref() {
- log::warn!(
- "Provider '{}' pending request returned no backend name; using predicted name '{}'",
- provider.provider_name(),
- backend_name,
- );
- }
- predicted_backend_name.clone()
- });
+ let backend_name = pending
+ .backend_name()
+ .map(str::to_string)
+ .or_else(|| provider.backend_name(context.services, effective_timeout));
let Some(backend_name) = backend_name else {
log::warn!(
"Provider '{}' pending request has no backend name; response cannot be correlated",
@@ -1056,9 +1470,14 @@ impl AuctionOrchestrator {
));
continue;
};
+ // Post-launch defense: a resolved backend name already
+ // claimed by another provider would misattribute that
+ // provider's response, so fail this dispatch attributably
+ // instead of overwriting the correlation entry.
if backend_to_provider.contains_key(&backend_name) {
log::warn!(
- "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping dispatch",
+ "Provider '{}' resolved to backend name '{}' already claimed by another \
+ provider this auction; skipping launch to avoid response misattribution",
provider.provider_name(),
backend_name,
);
@@ -1088,7 +1507,10 @@ impl AuctionOrchestrator {
}
Ok(ProviderRequestOutcome::Immediate(response)) => {
immediate_response_count += 1;
- completed_responses.push(response);
+ completed_responses.push(canonical_provider_response(
+ provider.provider_name(),
+ response,
+ ));
}
Err(e) => {
let response_time_ms = start_time.elapsed().as_millis() as u64;
@@ -1105,18 +1527,6 @@ impl AuctionOrchestrator {
}
}
- if pending_requests.is_empty() && immediate_response_count == 0 {
- return if completed_responses.is_empty() {
- DispatchAuctionOutcome::NotStarted
- } else {
- DispatchAuctionOutcome::DispatchFailed {
- request: request.clone(),
- provider_responses: completed_responses,
- elapsed_ms: auction_start.elapsed().as_millis() as u64,
- }
- };
- }
-
log::info!(
"Dispatched {} SSP request(s) with {} immediate response(s) (timeout: {}ms)",
pending_requests.len(),
@@ -1124,7 +1534,7 @@ impl AuctionOrchestrator {
context.timeout_ms
);
- DispatchAuctionOutcome::Dispatched(DispatchedAuction {
+ DispatchedAuction {
pending_requests,
backend_to_provider,
completed_responses,
@@ -1133,7 +1543,7 @@ impl AuctionOrchestrator {
floor_prices: self.floor_prices_by_slot(request),
provider_request_context: Box::new(snapshot_context_request(context.request)),
request: request.clone(),
- })
+ }
}
/// Collect bid responses from a previously-dispatched auction.
@@ -1226,7 +1636,10 @@ impl AuctionOrchestrator {
auction_response.bids.len(),
auction_response.response_time_ms
);
- responses.push(auction_response);
+ responses.push(canonical_provider_response(
+ &state.provider_name,
+ auction_response,
+ ));
}
Err(e) => {
log::warn!(
@@ -1302,50 +1715,25 @@ impl AuctionOrchestrator {
));
}
backend_to_provider.clear();
-
- let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator {
- match self.providers.get(mediator_name.as_str()) {
- Some(mediator) => {
- // Cap the mediator at whichever is tighter: its own configured
- // timeout or the remaining auction budget (A_deadline). Backend
- // first-byte and between-bytes timeouts bound normal collection, but
- // they are transport timers rather than absolute wall-clock limits:
- // connection setup and byte-trickling can still consume more of the
- // auction budget. Recomputing the remaining budget here prevents the
- // mediator from extending that bounded response hold.
- let remaining = remaining_budget_ms(auction_start, timeout_ms);
+ let normalized = self.normalize_provider_responses(&request, &mut responses);
+ let mut mediation_failed = false;
+ let mut mediator_response = None;
+ let mut mediated_winners = None;
+
+ if let Some(mediator_name) = &self.config.mediator {
+ if let Some(mediator) = self.providers.get(mediator_name.as_str()) {
+ let remaining = remaining_budget_ms(auction_start, timeout_ms);
+ if remaining == 0 {
+ log::warn!(
+ "A_deadline exhausted before mediator '{}' — using direct fallback",
+ mediator.provider_name(),
+ );
+ mediation_failed = true;
+ } else {
let mediator_timeout = services
.backend()
.canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms());
- if mediator_timeout == 0 {
- log::warn!(
- "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation",
- mediator.provider_name(),
- responses.len(),
- );
- let winning = self.select_winning_bids(&responses, &floor_prices);
- return OrchestrationResult {
- provider_responses: responses,
- mediator_response: None,
- winning_bids: winning,
- total_time_ms: auction_start.elapsed().as_millis() as u64,
- metadata: HashMap::new(),
- };
- }
let mediator_start = Instant::now();
- log::info!(
- "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)",
- mediator.provider_name(),
- mediator_timeout,
- remaining,
- mediator.timeout_ms(),
- );
- // The mediator runs on the collect path. See the doc-comment on
- // `AuctionContext::request`: the real client request was already
- // consumed by `send_async` during dispatch, so we substitute a
- // canonical placeholder URL. Any future mediator that needs real
- // client headers must snapshot them at dispatch time onto
- // `DispatchedAuction` rather than reading `context.request` here.
let placeholder = http::Request::builder()
.uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL)
.body(edgezero_core::body::Body::empty())
@@ -1357,93 +1745,84 @@ impl AuctionOrchestrator {
provider_responses: Some(&responses),
services: context.services,
};
- let mediator_response =
+ let raw_response =
match mediator.request_bids(&request, &mediator_context).await {
Ok(ProviderRequestOutcome::Immediate(response)) => Some(response),
Ok(ProviderRequestOutcome::Pending {
request: pending,
parse_state,
- }) => match services.http_client().wait(pending).await.change_context(
- TrustedServerError::Auction {
- message: format!(
- "Mediator {} request failed",
- mediator.provider_name()
- ),
- },
- ) {
- Ok(platform_resp) => match mediator
+ }) => match services.http_client().wait(pending).await {
+ Ok(platform_response) => mediator
.parse_response_with_context_and_state(
- platform_resp,
+ platform_response,
mediator_start.elapsed().as_millis() as u64,
&request,
&mediator_context,
parse_state.as_deref(),
)
.await
- {
- Ok(response) => Some(response),
- Err(error) => {
+ .inspect_err(|error| {
log::warn!(
- "Mediator '{}' parse failed: {:?}",
- mediator.provider_name(),
- error
+ "Mediator '{}' parse failed: {error:?}",
+ mediator.provider_name()
);
- None
- }
- },
+ })
+ .ok(),
Err(error) => {
- log::warn!("Mediator request failed: {:?}", error);
+ log::warn!("Mediator request failed: {error:?}");
None
}
},
Err(error) => {
log::warn!(
- "Mediator '{}' failed to dispatch: {:?}",
- mediator.provider_name(),
- error
+ "Mediator '{}' failed to dispatch: {error:?}",
+ mediator.provider_name()
);
None
}
};
-
- if let Some(mediator_response) = mediator_response {
- let winning = mediator_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(),
- bid.slot_id
- );
- None
- } else {
- Some((bid.slot_id.clone(), bid.clone()))
- }
- })
- .collect();
- let winning = self.apply_floor_prices(winning, &floor_prices);
- (Some(mediator_response), winning)
+ if let Some(raw_response) = raw_response {
+ mediator_response = Some(raw_response.clone());
+ match Self::resolve_mediator_candidates(
+ raw_response,
+ &normalized.candidates,
+ ) {
+ Ok(resolved) => {
+ let selected = resolved
+ .bids
+ .iter()
+ .map(|bid| (bid.slot_id.clone(), bid.clone()))
+ .collect();
+ mediated_winners =
+ Some(self.apply_floor_prices(selected, &floor_prices));
+ mediator_response = Some(resolved);
+ }
+ Err(()) => mediation_failed = true,
+ }
} else {
- (None, self.select_winning_bids(&responses, &floor_prices))
+ mediation_failed = true;
}
}
- None => {
- // lgtm[rust/cleartext-logging]
- // The mediator name is a static config identifier, not a secret.
- log::warn!("Mediator '{}' not registered", mediator_name);
- (None, self.select_winning_bids(&responses, &floor_prices))
- }
+ } else {
+ log::warn!("Mediator '{}' not registered", mediator_name);
+ mediation_failed = true;
}
- } else {
- (None, self.select_winning_bids(&responses, &floor_prices))
- };
+ }
+
+ let winning_bids =
+ mediated_winners.unwrap_or_else(|| self.select_winning_bids(&responses, &floor_prices));
+ let decision_set = self.build_decision_set(
+ &request,
+ &normalized.outcomes,
+ &winning_bids,
+ mediation_failed,
+ );
OrchestrationResult {
provider_responses: responses,
mediator_response,
winning_bids,
+ decision_set,
total_time_ms: auction_start.elapsed().as_millis() as u64,
metadata: HashMap::new(),
}
@@ -1465,6 +1844,8 @@ pub struct OrchestrationResult {
pub mediator_response: Option,
/// Winning bids per slot
pub winning_bids: HashMap,
+ /// Exact ordered decision for every requested slot.
+ pub decision_set: AuctionDecisionSetV1,
/// Total orchestration time in milliseconds
pub total_time_ms: u64,
/// Metadata about the auction
@@ -1501,12 +1882,14 @@ mod tests {
use web_time::Instant;
use crate::auction::config::AuctionConfig;
- use crate::auction::orchestrator::DispatchAuctionOutcome;
- use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome};
+ use crate::auction::provider::{
+ AuctionProvider, ProviderRequestOutcome, ProviderSlotDisposition,
+ };
use crate::auction::test_support::create_test_auction_context;
use crate::auction::types::{
- AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest,
- AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo,
+ AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionDropReason,
+ AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidRenderSourceV1,
+ BidStatus, MediaType, PublisherInfo, SlotAuctionDecisionV1, UserInfo,
};
use crate::error::TrustedServerError;
use crate::platform::test_support::{
@@ -1520,9 +1903,10 @@ mod tests {
use crate::test_support::tests::crate_test_settings_str;
use error_stack::{Report, ResultExt};
use std::collections::{HashMap, HashSet};
+ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
- use super::AuctionOrchestrator;
+ use super::{AuctionIdentityGenerator, AuctionOrchestrator};
// ---------------------------------------------------------------------------
// Minimal test double for AuctionProvider
@@ -1531,6 +1915,46 @@ mod tests {
struct StubAuctionProvider {
name: &'static str,
backend: &'static str,
+ configured_timeout_ms: u32,
+ predicted_timeouts: Option>>>,
+ request_timeouts: Option>>>,
+ }
+
+ impl StubAuctionProvider {
+ fn new(name: &'static str, backend: &'static str) -> Self {
+ Self {
+ name,
+ backend,
+ configured_timeout_ms: 125,
+ predicted_timeouts: None,
+ request_timeouts: None,
+ }
+ }
+
+ fn recording(
+ name: &'static str,
+ backend: &'static str,
+ configured_timeout_ms: u32,
+ predicted_timeouts: Arc>>,
+ request_timeouts: Arc>>,
+ ) -> Self {
+ Self {
+ name,
+ backend,
+ configured_timeout_ms,
+ predicted_timeouts: Some(predicted_timeouts),
+ request_timeouts: Some(request_timeouts),
+ }
+ }
+
+ fn record(slot: &Option>>>, timeout_ms: u32) {
+ if let Some(observed) = slot {
+ observed
+ .lock()
+ .expect("should lock observed timeouts")
+ .push(timeout_ms);
+ }
+ }
}
#[async_trait::async_trait(?Send)]
@@ -1544,6 +1968,7 @@ mod tests {
_request: &AuctionRequest,
context: &AuctionContext<'_>,
) -> Result> {
+ Self::record(&self.request_timeouts, context.timeout_ms);
let req = PlatformHttpRequest::new(
http::Request::builder()
.method("POST")
@@ -1594,82 +2019,18 @@ mod tests {
.with_metadata("context_timeout_ms", serde_json::json!(context.timeout_ms)))
}
- fn timeout_ms(&self) -> u32 {
- 125
- }
-
- fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option {
- Some(self.backend.to_string())
- }
- }
-
- struct RecordingTimeoutProvider {
- name: &'static str,
- backend: &'static str,
- configured_timeout_ms: u32,
- predicted: Arc>>,
- requested: Arc>>,
- }
-
- #[async_trait::async_trait(?Send)]
- impl AuctionProvider for RecordingTimeoutProvider {
- fn provider_name(&self) -> &'static str {
- self.name
- }
-
- async fn request_bids(
- &self,
- _request: &AuctionRequest,
- context: &AuctionContext<'_>,
- ) -> Result> {
- self.requested
- .lock()
- .expect("should lock requested timeouts")
- .push(context.timeout_ms);
- let request = PlatformHttpRequest::new(
- http::Request::builder()
- .method("POST")
- .uri("https://example.com/bid")
- .body(edgezero_core::body::Body::empty())
- .expect("should build recording request"),
- self.backend,
- );
- context
- .services
- .http_client()
- .send_async(request)
- .await
- .change_context(TrustedServerError::Auction {
- message: "recording launch failed".to_string(),
- })
- .map(ProviderRequestOutcome::pending)
- }
-
- async fn parse_response(
- &self,
- _response: PlatformResponse,
- response_time_ms: u64,
- ) -> Result> {
- Ok(AuctionResponse::success(
- self.name,
- vec![],
- response_time_ms,
- ))
- }
-
fn timeout_ms(&self) -> u32 {
self.configured_timeout_ms
}
fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option {
- self.predicted
- .lock()
- .expect("should lock predicted timeouts")
- .push(timeout_ms);
+ Self::record(&self.predicted_timeouts, timeout_ms);
Some(self.backend.to_string())
}
}
+ /// Provider whose `backend_name` prediction deliberately differs from the
+ /// backend name its `request_bids` puts on the wire.
struct DivergentBackendProvider {
name: &'static str,
predicted: &'static str,
@@ -1687,7 +2048,7 @@ mod tests {
_request: &AuctionRequest,
context: &AuctionContext<'_>,
) -> Result> {
- let request = PlatformHttpRequest::new(
+ let req = PlatformHttpRequest::new(
http::Request::builder()
.method("POST")
.uri("https://example.com/bid")
@@ -1698,7 +2059,7 @@ mod tests {
context
.services
.http_client()
- .send_async(request)
+ .send_async(req)
.await
.change_context(TrustedServerError::Auction {
message: "divergent launch failed".to_string(),
@@ -1759,14 +2120,14 @@ mod tests {
configured_timeout_ms: u32,
predicted: &Arc>>,
requested: &Arc>>,
- ) -> RecordingTimeoutProvider {
- RecordingTimeoutProvider {
+ ) -> StubAuctionProvider {
+ StubAuctionProvider::recording(
name,
backend,
configured_timeout_ms,
- predicted: Arc::clone(predicted),
- requested: Arc::clone(requested),
- }
+ Arc::clone(predicted),
+ Arc::clone(requested),
+ )
}
/// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring
@@ -1776,7 +2137,7 @@ mod tests {
fn auction_bid(bidder: &str, price: f64) -> Bid {
let renderer = (bidder == "aps").then(|| {
- BidRenderer::Aps(ApsRendererV1 {
+ BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "aps-selected-bid".to_string(),
@@ -1790,6 +2151,9 @@ mod tests {
});
Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(price),
currency: "USD".to_string(),
creative: renderer
@@ -1810,28 +2174,131 @@ mod tests {
cache_path: None,
metadata: HashMap::new(),
}
- }
+ }
+
+ struct CounterIdentityGenerator {
+ draws: AtomicUsize,
+ }
+
+ impl CounterIdentityGenerator {
+ fn new() -> Self {
+ Self {
+ draws: AtomicUsize::new(0),
+ }
+ }
+ }
+
+ impl AuctionIdentityGenerator for CounterIdentityGenerator {
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()> {
+ destination.fill(0);
+ let draw = self.draws.fetch_add(1, Ordering::SeqCst) + 1;
+ let last = destination.last_mut().ok_or(())?;
+ *last = u8::try_from(draw).map_err(|_| ())?;
+ Ok(())
+ }
+ }
+
+ struct FixedIdentityGenerator {
+ draws: AtomicUsize,
+ }
+
+ impl FixedIdentityGenerator {
+ fn new() -> Self {
+ Self {
+ draws: AtomicUsize::new(0),
+ }
+ }
+ }
+
+ impl AuctionIdentityGenerator for FixedIdentityGenerator {
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()> {
+ self.draws.fetch_add(1, Ordering::SeqCst);
+ destination.fill(0);
+ Ok(())
+ }
+ }
+
+ fn mediated_bid(nurl: Option) -> Bid {
+ Bid {
+ slot_id: "header-banner".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
+ price: Some(2.5),
+ currency: "USD".to_string(),
+ creative: Some("ad
".to_string()),
+ adomain: None,
+ bidder: "mediator".to_string(),
+ width: 728,
+ height: 90,
+ nurl: nurl.clone(),
+ burl: nurl,
+ bid_id: None,
+ ad_id: Some("creative-123".to_string()),
+ creative_id: None,
+ renderer: None,
+ cache_id: Some("cache-abc".to_string()),
+ cache_host: None,
+ cache_path: None,
+ metadata: HashMap::new(),
+ }
+ }
+
+ struct SourceBidProvider {
+ nurl: &'static str,
+ }
+
+ #[async_trait::async_trait(?Send)]
+ impl AuctionProvider for SourceBidProvider {
+ fn provider_name(&self) -> &'static str {
+ "bidder"
+ }
+
+ async fn request_bids(
+ &self,
+ _request: &AuctionRequest,
+ context: &AuctionContext<'_>,
+ ) -> Result> {
+ let request = PlatformHttpRequest::new(
+ http::Request::builder()
+ .method("POST")
+ .uri("https://example.com/bid")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build source bid request"),
+ "bidder-backend",
+ );
+ context
+ .services
+ .http_client()
+ .send_async(request)
+ .await
+ .change_context(TrustedServerError::Auction {
+ message: "source bidder launch failed".to_string(),
+ })
+ .map(ProviderRequestOutcome::pending)
+ }
+
+ async fn parse_response(
+ &self,
+ _response: PlatformResponse,
+ response_time_ms: u64,
+ ) -> Result> {
+ let mut bid = mediated_bid(Some(self.nurl.to_string()));
+ bid.price = Some(1.0);
+ bid.bid_id = Some("source-bid-id".to_string());
+ Ok(AuctionResponse::success(
+ self.provider_name(),
+ vec![bid],
+ response_time_ms,
+ ))
+ }
+
+ fn timeout_ms(&self) -> u32 {
+ 2000
+ }
- fn mediated_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(),
- width: 728,
- height: 90,
- nurl: nurl.clone(),
- burl: nurl,
- bid_id: None,
- ad_id: Some("creative-123".to_string()),
- creative_id: None,
- renderer: None,
- cache_id: Some("cache-abc".to_string()),
- cache_host: None,
- cache_path: None,
- metadata: HashMap::new(),
+ fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option {
+ Some("bidder-backend".to_string())
}
}
@@ -1883,12 +2350,18 @@ mod tests {
_response: PlatformResponse,
response_time_ms: u64,
_request: &AuctionRequest,
- _context: &AuctionContext<'_>,
+ context: &AuctionContext<'_>,
) -> Result> {
- // Context-aware path: restores nurl/ad_id from the collected SSP bids.
+ let mut selection = context
+ .provider_responses
+ .and_then(|responses| responses.first())
+ .and_then(|response| response.bids.first())
+ .cloned()
+ .expect("should provide one source candidate to mediator");
+ selection.price = Some(2.5);
Ok(AuctionResponse::success(
"mediator",
- vec![mediated_bid(Some("https://nurl.example/win".to_string()))],
+ vec![selection],
response_time_ms,
))
}
@@ -1913,13 +2386,18 @@ mod tests {
async fn request_bids(
&self,
_request: &AuctionRequest,
- _context: &AuctionContext<'_>,
+ context: &AuctionContext<'_>,
) -> Result> {
+ let mut selection = context
+ .provider_responses
+ .and_then(|responses| responses.first())
+ .and_then(|response| response.bids.first())
+ .cloned()
+ .expect("should provide one source candidate to immediate mediator");
+ selection.price = Some(2.5);
Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success(
self.provider_name(),
- vec![mediated_bid(Some(
- "https://nurl.example/immediate".to_string(),
- ))],
+ vec![selection],
0,
)))
}
@@ -1958,9 +2436,8 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "bidder",
- backend: "bidder-backend",
+ orchestrator.register_provider(Arc::new(SourceBidProvider {
+ nurl: "https://nurl.example/win",
}));
orchestrator.register_provider(Arc::new(CacheRestoringMediator));
@@ -2014,9 +2491,8 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "bidder",
- backend: "bidder-backend",
+ orchestrator.register_provider(Arc::new(SourceBidProvider {
+ nurl: "https://nurl.example/immediate",
}));
orchestrator.register_provider(Arc::new(ImmediateMediator));
let request = create_test_auction_request();
@@ -2031,11 +2507,7 @@ mod tests {
};
let result = if split {
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("bidder request should dispatch");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await
@@ -2105,6 +2577,374 @@ mod tests {
}
}
+ fn one_slot_request() -> AuctionRequest {
+ let mut request = create_test_auction_request();
+ request.slots = vec![AdSlot {
+ id: "slot-1".to_string(),
+ formats: vec![AdFormat {
+ media_type: MediaType::Banner,
+ width: 300,
+ height: 250,
+ }],
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ }];
+ request
+ }
+
+ fn enabled_config(providers: &[&str]) -> AuctionConfig {
+ AuctionConfig {
+ enabled: true,
+ providers: providers
+ .iter()
+ .map(|provider| (*provider).to_string())
+ .collect(),
+ ..AuctionConfig::default()
+ }
+ }
+
+ #[test]
+ fn normalized_provider_outcomes_cover_every_dispatched_slot() {
+ let generator = Arc::new(CounterIdentityGenerator::new());
+ let mut orchestrator =
+ AuctionOrchestrator::with_identity_generator(enabled_config(&["alpha"]), generator);
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let request = one_slot_request();
+ let mut candidate = auction_bid("aps", 2.0);
+ candidate.slot_id = "slot-1".to_string();
+ let mut responses = vec![AuctionResponse::success("alpha", vec![candidate], 10)];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert_eq!(normalized.outcomes.len(), 1);
+ assert_eq!(normalized.outcomes[0].provider, "alpha");
+ assert_eq!(normalized.outcomes[0].slot, "slot-1");
+ assert!(matches!(
+ &normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Candidates(candidates)
+ if candidates.len() == 1
+ && candidates[0].candidate_id.as_deref().is_some_and(|id| id.len() == 12)
+ ));
+
+ let mut no_bid = vec![AuctionResponse::no_bid("alpha", 10)];
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut no_bid);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::NoBid
+ ));
+
+ let mut timeout = vec![super::provider_timeout_response("alpha", 10)];
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut timeout);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout)
+ ));
+
+ let mut attributable_invalid = vec![AuctionResponse::no_bid("alpha", 10)];
+ attributable_invalid[0].metadata.insert(
+ "invalid_slots".to_string(),
+ serde_json::json!({"slot-1": "invalid_provider_response"}),
+ );
+ let normalized =
+ orchestrator.normalize_provider_responses(&request, &mut attributable_invalid);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InvalidProviderResponse)
+ ));
+ }
+
+ #[test]
+ fn provider_failure_classes_map_to_closed_slot_reasons() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let request = one_slot_request();
+
+ for (error_type, expected) in [
+ (
+ super::ERROR_TYPE_LAUNCH_FAILED,
+ AuctionSlotFailureReason::ProviderError,
+ ),
+ (
+ super::ERROR_TYPE_TRANSPORT,
+ AuctionSlotFailureReason::ProviderError,
+ ),
+ (
+ super::ERROR_TYPE_HTTP_STATUS,
+ AuctionSlotFailureReason::ProviderError,
+ ),
+ (
+ super::ERROR_TYPE_PARSE_RESPONSE,
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ ),
+ ] {
+ let error = Report::new(TrustedServerError::Auction {
+ message: "provider failed".to_string(),
+ });
+ let mut responses = vec![super::provider_error_response(
+ "alpha", 1, error_type, &error,
+ )];
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(reason) if reason == expected
+ ));
+ }
+ }
+
+ #[test]
+ fn candidate_collision_exhaustion_fails_only_the_affected_slot() {
+ let generator = Arc::new(FixedIdentityGenerator::new());
+ let mut orchestrator = AuctionOrchestrator::with_identity_generator(
+ enabled_config(&["alpha"]),
+ generator.clone(),
+ );
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let mut request = one_slot_request();
+ request.slots.push(AdSlot {
+ id: "slot-2".to_string(),
+ formats: request.slots[0].formats.clone(),
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ });
+ let mut first = auction_bid("aps", 2.0);
+ first.slot_id = "slot-1".to_string();
+ first.bid_id = Some("upstream-1".to_string());
+ let mut second = auction_bid("aps", 1.0);
+ second.slot_id = "slot-2".to_string();
+ second.bid_id = Some("upstream-2".to_string());
+ let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert_eq!(generator.draws.load(Ordering::SeqCst), 10);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Candidates(_)
+ ));
+ assert!(matches!(
+ normalized.outcomes[1].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError)
+ ));
+ }
+
+ #[test]
+ fn candidate_collision_exhaustion_discards_earlier_sibling_for_same_slot() {
+ let generator = Arc::new(FixedIdentityGenerator::new());
+ let mut orchestrator = AuctionOrchestrator::with_identity_generator(
+ enabled_config(&["alpha"]),
+ generator.clone(),
+ );
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let request = one_slot_request();
+ let mut first = auction_bid("aps", 2.0);
+ first.bid_id = Some("upstream-1".to_string());
+ let mut second = auction_bid("aps", 1.0);
+ second.bid_id = Some("upstream-2".to_string());
+ let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert_eq!(generator.draws.load(Ordering::SeqCst), 10);
+ assert!(responses[0].bids.is_empty());
+ assert!(normalized.candidates.is_empty());
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError)
+ ));
+ }
+
+ #[test]
+ fn per_bid_drop_does_not_poison_an_unrelated_missing_slot() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let mut request = one_slot_request();
+ request.slots.push(AdSlot {
+ id: "slot-2".to_string(),
+ formats: request.slots[0].formats.clone(),
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ });
+ let mut valid = auction_bid("aps", 2.0);
+ valid.bid_id = Some("upstream-1".to_string());
+ let mut response = AuctionResponse::success("alpha", vec![valid], 10);
+ response = response.with_drop_reason(AuctionDropReason::InvalidDimensions);
+ let mut responses = vec![response];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Candidates(_)
+ ));
+ assert!(matches!(
+ normalized.outcomes[1].disposition,
+ ProviderSlotDisposition::NoBid
+ ));
+ }
+
+ #[test]
+ fn final_decisions_are_request_ordered_and_use_closed_failure_priority() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta")));
+ let request = one_slot_request();
+ let outcomes = vec![
+ crate::auction::provider::ProviderSlotOutcome {
+ provider: "alpha".to_string(),
+ slot: "slot-1".to_string(),
+ disposition: ProviderSlotDisposition::Failed(
+ AuctionSlotFailureReason::ProviderTimeout,
+ ),
+ },
+ crate::auction::provider::ProviderSlotOutcome {
+ provider: "zeta".to_string(),
+ slot: "slot-1".to_string(),
+ disposition: ProviderSlotDisposition::Failed(
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ ),
+ },
+ ];
+
+ let decisions = orchestrator.build_decision_set(&request, &outcomes, &HashMap::new(), true);
+
+ assert_eq!(decisions.results.len(), 1);
+ assert!(matches!(
+ &decisions.results[0],
+ SlotAuctionDecisionV1::Failed { slot, reason }
+ if slot == "slot-1" && *reason == AuctionSlotFailureReason::MediationFailed
+ ));
+ assert_eq!(
+ serde_json::to_string(&decisions).expect("decision set should serialize"),
+ r#"{"version":1,"auctionId":"test-auction-123","results":[{"slot":"slot-1","outcome":"failed","reason":"mediation_failed"}]}"#
+ );
+ assert_eq!(
+ serde_json::to_string(&SlotAuctionDecisionV1::Failed {
+ slot: "slot-1".to_string(),
+ reason: AuctionSlotFailureReason::IdentityGenerationFailed,
+ })
+ .expect("direct identity-generation failure should serialize"),
+ r#"{"slot":"slot-1","outcome":"failed","reason":"identity_generation_failed"}"#
+ );
+ }
+
+ #[test]
+ fn deliverable_winner_beats_a_sibling_provider_failure() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta")));
+ let request = one_slot_request();
+ let mut winner = auction_bid("alpha-seat", 2.0);
+ winner.candidate_id = Some("AAAAAAAAAAAA".to_string());
+ winner.candidate_provider = Some("alpha".to_string());
+ winner.bid_id = Some("upstream-alpha".to_string());
+ let outcomes = vec![crate::auction::provider::ProviderSlotOutcome {
+ provider: "zeta".to_string(),
+ slot: "slot-1".to_string(),
+ disposition: ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout),
+ }];
+
+ let decisions = orchestrator.build_decision_set(
+ &request,
+ &outcomes,
+ &HashMap::from([("slot-1".to_string(), winner)]),
+ true,
+ );
+
+ assert_eq!(
+ decisions.results,
+ vec![SlotAuctionDecisionV1::Winner {
+ slot: "slot-1".to_string(),
+ candidate_id: "AAAAAAAAAAAA".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn direct_ties_ignore_arrival_and_candidate_ids() {
+ let orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"]));
+ let mut alpha = auction_bid("seat-a", 2.0);
+ alpha.candidate_provider = Some("alpha".to_string());
+ alpha.candidate_id = Some("zzzzzzzzzzzz".to_string());
+ alpha.bid_id = Some("upstream-z".to_string());
+ let mut zeta = auction_bid("seat-z", 2.0);
+ zeta.candidate_provider = Some("zeta".to_string());
+ zeta.candidate_id = Some("AAAAAAAAAAAA".to_string());
+ zeta.bid_id = Some("upstream-a".to_string());
+ let left = AuctionResponse::success("alpha", vec![alpha], 1);
+ let right = AuctionResponse::success("zeta", vec![zeta], 1);
+
+ for responses in [vec![left.clone(), right.clone()], vec![right, left]] {
+ let winners = orchestrator.select_winning_bids(&responses, &HashMap::new());
+ assert_eq!(
+ winners["slot-1"].candidate_provider.as_deref(),
+ Some("alpha")
+ );
+ }
+ }
+
+ #[test]
+ fn mediator_can_select_only_known_candidate_provenance() {
+ let mut source = auction_bid("aps", 1.0);
+ source.candidate_id = Some("AAAAAAAAAAAA".to_string());
+ source.candidate_provider = Some("aps".to_string());
+ source.nurl = Some("https://source.example/win".to_string());
+ let candidates = HashMap::from([("AAAAAAAAAAAA".to_string(), source.clone())]);
+ let mut selection = source.clone();
+ selection.price = Some(9.0);
+
+ let resolved = AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![selection], 2),
+ &candidates,
+ )
+ .expect("known candidate should resolve");
+ assert_eq!(resolved.bids[0].price, Some(9.0));
+ assert_eq!(resolved.bids[0].width, source.width);
+ assert_eq!(resolved.bids[0].height, source.height);
+ assert_eq!(resolved.bids[0].renderer, source.renderer);
+ assert_eq!(resolved.bids[0].nurl, source.nurl);
+
+ let mut substituted = source.clone();
+ substituted.price = Some(9.0);
+ substituted.width = 1;
+ assert!(
+ AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![substituted], 2),
+ &candidates,
+ )
+ .is_err(),
+ "mediator source-field substitutions should fail provenance validation"
+ );
+
+ let mut second_source = source.clone();
+ second_source.candidate_id = Some("BBBBBBBBBBBB".to_string());
+ second_source.bid_id = Some("upstream-2".to_string());
+ let same_slot_candidates = HashMap::from([
+ ("AAAAAAAAAAAA".to_string(), source.clone()),
+ ("BBBBBBBBBBBB".to_string(), second_source.clone()),
+ ]);
+ assert!(
+ AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![source.clone(), second_source], 2),
+ &same_slot_candidates,
+ )
+ .is_err(),
+ "a mediator may select at most one candidate for a slot"
+ );
+
+ let mut unknown = source;
+ unknown.candidate_id = Some("BBBBBBBBBBBB".to_string());
+ assert!(
+ AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![unknown], 2),
+ &candidates,
+ )
+ .is_err()
+ );
+ }
+
fn create_test_settings() -> crate::settings::Settings {
let settings_str = crate_test_settings_str();
crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings")
@@ -2216,6 +3056,17 @@ mod tests {
assert_eq!(result.provider_responses.len(), 1);
assert_eq!(result.provider_responses[0].status, BidStatus::NoBid);
assert!(result.winning_bids.is_empty());
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::NoBid {
+ slot: "header-banner".to_string(),
+ },
+ SlotAuctionDecisionV1::NoBid {
+ slot: "sidebar".to_string(),
+ },
+ ]
+ );
}
#[tokio::test]
@@ -2233,12 +3084,9 @@ mod tests {
let downstream = http::Request::new(edgezero_core::body::Body::empty());
let context = immediate_test_context(&settings, &downstream, &services);
- let DispatchAuctionOutcome::Dispatched(dispatched) = orchestrator
+ let dispatched = orchestrator
.dispatch_auction(&create_test_auction_request(), &context)
- .await
- else {
- panic!("enabled immediate provider should dispatch");
- };
+ .await;
let result = orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
@@ -2246,6 +3094,17 @@ mod tests {
assert_eq!(result.provider_responses.len(), 1);
assert_eq!(result.provider_responses[0].status, BidStatus::NoBid);
assert!(result.winning_bids.is_empty());
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::NoBid {
+ slot: "header-banner".to_string(),
+ },
+ SlotAuctionDecisionV1::NoBid {
+ slot: "sidebar".to_string(),
+ },
+ ]
+ );
}
#[tokio::test]
@@ -2259,10 +3118,10 @@ mod tests {
};
let mut orchestrator = AuctionOrchestrator::new(config);
orchestrator.register_provider(Arc::new(ImmediateNoBidProvider));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "pending",
- backend: "pending-backend",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "pending",
+ "pending-backend",
+ )));
let stub = Arc::new(StubHttpClient::new());
stub.push_response(200, b"{}".to_vec());
let services = build_services_with_http_client(stub);
@@ -2272,11 +3131,7 @@ mod tests {
let request = create_test_auction_request();
let result = if split {
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("mixed immediate/pending auction should dispatch");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await
@@ -2406,6 +3261,9 @@ mod tests {
"slot-1".to_string(),
Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(0.50),
currency: "USD".to_string(),
creative: Some("Ad
".to_string()),
@@ -2429,6 +3287,9 @@ mod tests {
"slot-2".to_string(),
Bid {
slot_id: "slot-2".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(2.00),
currency: "USD".to_string(),
creative: Some("Ad
".to_string()),
@@ -2499,14 +3360,25 @@ mod tests {
let result = orchestrator.run_auction(&request, &context).await;
- assert!(result.is_err());
- let err = result.unwrap_err();
- assert!(format!("{}", err).contains("No providers configured"));
+ let result = result.expect("should return one decision per requested slot");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::SlotNotEligible,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::SlotNotEligible,
+ },
+ ]
+ );
});
}
#[test]
- fn provider_launch_failures_error_when_no_requests_launch() {
+ fn provider_launch_failures_are_explicit_when_no_requests_launch() {
futures::executor::block_on(async {
let config = AuctionConfig {
enabled: true,
@@ -2526,16 +3398,20 @@ mod tests {
.expect("should build request");
let context = create_test_auction_context(&settings, &req, 2000);
- let error = orchestrator
- .run_auction(&request, &context)
- .await
- .expect_err("should fail when every provider launch fails");
-
- assert!(
- error
- .to_string()
- .contains("All 1 configured provider(s) skipped or failed to launch"),
- "should explain that no configured provider request launched"
+ let result = orchestrator.run_auction(&request, &context).await;
+ let result = result.expect("should preserve launch failures as slot decisions");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ ]
);
});
}
@@ -2579,14 +3455,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "shared-backend",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "shared-backend",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "shared-backend",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "shared-backend",
+ )));
let stub = Arc::new(StubHttpClient::new());
stub.push_response(200, b"{}".to_vec());
let services = build_services_with_http_client(stub);
@@ -2596,11 +3472,7 @@ mod tests {
let request = create_test_auction_request();
let result = if split {
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch the first provider");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await
@@ -2721,13 +3593,17 @@ mod tests {
#[test]
fn zero_canonical_timeout_skips_parallel_launch() {
futures::executor::block_on(async {
+ // A platform that canonicalizes to zero signals "budget exhausted";
+ // the orchestrator must skip the launch and retain an attributable
+ // timeout decision for every eligible requested slot.
+ let stub = Arc::new(StubHttpClient::new());
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(StubHttpClient::new()),
+ stub,
);
let predicted = Arc::new(Mutex::new(Vec::new()));
let requested = Arc::new(Mutex::new(Vec::new()));
@@ -2747,14 +3623,80 @@ mod tests {
let settings = create_test_settings();
let downstream = http::Request::new(edgezero_core::body::Body::empty());
let context = immediate_test_context(&settings, &downstream, &services);
+ let request = create_test_auction_request();
let result = orchestrator
- .run_auction(&create_test_auction_request(), &context)
+ .run_auction(&request, &context)
+ .await
+ .expect("should preserve an exhausted budget as slot decisions");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ ]
+ );
+ });
+ }
+
+ #[test]
+ fn zero_canonical_timeout_is_attributable_in_split_dispatch() {
+ futures::executor::block_on(async {
+ let stub = Arc::new(StubHttpClient::new());
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ let services = build_services_with_backend_and_http_client(
+ Arc::new(CanonicalTimeoutBackend {
+ canonical_ms: 0,
+ 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: vec!["bidder".to_string()],
+ timeout_ms: 2000,
+ ..Default::default()
+ });
+ orchestrator.register_provider(Arc::new(recording_provider(
+ "bidder",
+ "bidder-backend",
+ 1000,
+ &predicted,
+ &requested,
+ )));
+ let settings = create_test_settings();
+ let downstream = http::Request::new(edgezero_core::body::Body::empty());
+ let context = immediate_test_context(&settings, &downstream, &services);
+ let request = create_test_auction_request();
+
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
+ let result = orchestrator
+ .collect_dispatched_auction(dispatched, &services, &context)
.await;
- assert!(result.is_err(), "zero budget should skip every provider");
assert!(predicted.lock().expect("should lock predicted").is_empty());
assert!(requested.lock().expect("should lock requested").is_empty());
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ ]
+ );
});
}
@@ -2781,10 +3723,10 @@ mod tests {
timeout_ms: 2000,
..Default::default()
});
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "bidder",
- backend: "bidder-backend",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "bidder",
+ "bidder-backend",
+ )));
orchestrator.register_provider(Arc::new(recording_provider(
"mediator",
"mediator-backend",
@@ -2850,11 +3792,7 @@ mod tests {
let context = immediate_test_context(&settings, &downstream, &services);
let request = create_test_auction_request();
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch bidder request");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
@@ -2902,18 +3840,21 @@ mod tests {
let context = immediate_test_context(&settings, &downstream, &services);
let request = create_test_auction_request();
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch provider");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
let result = orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
- assert!(result.provider_responses.iter().any(|response| {
- response.provider == "provider-a" && response.status == BidStatus::Success
- }));
+ let provider_a = result
+ .provider_responses
+ .iter()
+ .find(|response| response.provider == "provider-a")
+ .expect("should have provider-a response");
+ assert_eq!(
+ provider_a.status,
+ BidStatus::Success,
+ "response should correlate by the resolved backend name, not the prediction"
+ );
});
}
@@ -2945,21 +3886,23 @@ mod tests {
let context = immediate_test_context(&settings, &downstream, &services);
let request = create_test_auction_request();
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch first provider");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
let result = orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
- assert!(result.provider_responses.iter().any(|response| {
- response.provider == "provider-a" && response.status == BidStatus::Success
- }));
- assert!(result.provider_responses.iter().any(|response| {
- response.provider == "provider-b" && response.status == BidStatus::Error
- }));
+ let provider_a = result
+ .provider_responses
+ .iter()
+ .find(|response| response.provider == "provider-a")
+ .expect("should have provider-a response");
+ let provider_b = result
+ .provider_responses
+ .iter()
+ .find(|response| response.provider == "provider-b")
+ .expect("should have provider-b response");
+ assert_eq!(provider_a.status, BidStatus::Success);
+ assert_eq!(provider_b.status, BidStatus::Error);
});
}
@@ -2987,14 +3930,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "backend-b",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "backend-a",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "backend-b",
+ )));
let request = create_test_auction_request();
let settings = create_test_settings();
@@ -3062,10 +4005,9 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
+ let mut provider = StubAuctionProvider::new("provider-a", "backend-a");
+ provider.configured_timeout_ms = 125;
+ orchestrator.register_provider(Arc::new(provider));
let request = create_test_auction_request();
let settings = create_test_settings();
let downstream = http::Request::builder()
@@ -3080,13 +4022,9 @@ mod tests {
provider_responses: None,
services: &services,
};
- let dispatched = match orchestrator
+ let dispatched = orchestrator
.dispatch_auction(&request, &dispatch_context)
- .await
- {
- DispatchAuctionOutcome::Dispatched(dispatched) => dispatched,
- _ => panic!("should dispatch provider request"),
- };
+ .await;
let placeholder = http::Request::builder()
.uri("https://placeholder.invalid/")
.body(edgezero_core::body::Body::empty())
@@ -3140,14 +4078,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "backend-b",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "backend-a",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "backend-b",
+ )));
let request = create_test_auction_request();
let settings = create_test_settings();
@@ -3167,11 +4105,21 @@ mod tests {
// Act
let result = orchestrator.run_auction(&request, &context).await;
- // Assert: rejected before any provider request launches.
- let err = result.expect_err("should reject multi-provider fan-out");
- assert!(
- format!("{err}").contains("sequentially"),
- "should explain the sequential-execution limitation"
+ // Assert: every affected slot gets an explicit provider failure
+ // without launching either provider request.
+ let result = result.expect("should preserve sequential-platform failures");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ ]
);
assert!(
stub_for_assertion.recorded_backend_names().is_empty(),
@@ -3205,14 +4153,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "backend-b",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "backend-a",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "backend-b",
+ )));
let request = create_test_auction_request();
let settings = create_test_settings();
@@ -3232,15 +4180,27 @@ mod tests {
// Act
let dispatched = orchestrator.dispatch_auction(&request, &context).await;
- // Assert: no dispatch and no provider request launched.
- assert!(
- matches!(dispatched, DispatchAuctionOutcome::NotStarted),
- "should skip initial-page dispatch on sequential platforms"
- );
+ // Assert: no network request launches, but every configured provider
+ // remains attributable through the normal terminal decision path.
assert!(
stub_for_assertion.recorded_backend_names().is_empty(),
"should not launch any provider request on a sequential platform"
);
+ let result = orchestrator
+ .collect_dispatched_auction(dispatched, services, &context)
+ .await;
+ assert!(result.winning_bids.is_empty());
+ assert!(result.provider_responses.iter().all(|response| {
+ response.status == BidStatus::Error
+ && response.metadata["error_type"] == "launch_failed"
+ }));
+ assert!(result.decision_set.results.iter().all(|decision| matches!(
+ decision,
+ SlotAuctionDecisionV1::Failed {
+ reason: AuctionSlotFailureReason::ProviderError,
+ ..
+ }
+ )));
});
}
@@ -3290,6 +4250,9 @@ mod tests {
"slot-1".to_string(),
Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: None,
currency: "USD".to_string(),
creative: Some("Ad
".to_string()),
@@ -3334,6 +4297,9 @@ mod tests {
"atf".to_string(),
Bid {
slot_id: "atf".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(0.30), // decoded APS price — below $0.50 floor
currency: "USD".to_string(),
creative: Some("APS Ad
".to_string()),
@@ -3373,6 +4339,9 @@ mod tests {
"atf".to_string(),
Bid {
slot_id: "atf".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(0.75), // decoded APS price — above floor
currency: "USD".to_string(),
creative: Some("APS Ad
".to_string()),
diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs
index 766bd7a08..a3c6f012f 100644
--- a/crates/trusted-server-core/src/auction/provider.rs
+++ b/crates/trusted-server-core/src/auction/provider.rs
@@ -8,7 +8,31 @@ use error_stack::Report;
use crate::error::TrustedServerError;
use crate::platform::{PlatformPendingRequest, PlatformResponse, RuntimeServices};
-use super::types::{AuctionContext, AuctionRequest, AuctionResponse};
+use super::types::{
+ AuctionContext, AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid,
+};
+
+/// Exactly one normalized outcome for a slot dispatched to one provider.
+#[derive(Debug, Clone)]
+pub struct ProviderSlotOutcome {
+ /// Provider integration that received the slot.
+ pub provider: String,
+ /// Exact dispatched slot identifier.
+ pub slot: String,
+ /// Candidate, successful no-bid, or typed failure.
+ pub disposition: ProviderSlotDisposition,
+}
+
+/// Closed normalized provider result for one dispatched slot.
+#[derive(Debug, Clone)]
+pub enum ProviderSlotDisposition {
+ /// One or more independently validated candidates returned for the slot.
+ Candidates(Vec),
+ /// Provider completed successfully without a candidate for this slot.
+ NoBid,
+ /// Provider failed for this slot.
+ Failed(AuctionSlotFailureReason),
+}
/// Provider-local state carried from request dispatch to response parsing.
pub type ProviderParseState = Box;
diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs
index b3e049eaf..1ba73a655 100644
--- a/crates/trusted-server-core/src/auction/telemetry.rs
+++ b/crates/trusted-server-core/src/auction/telemetry.rs
@@ -933,7 +933,7 @@ mod tests {
use serde_json::json;
- use crate::auction::types::{AdFormat, AdSlot, PublisherInfo, UserInfo};
+ use crate::auction::types::{AdFormat, AdSlot, AuctionDecisionSetV1, PublisherInfo, UserInfo};
use super::*;
@@ -969,6 +969,9 @@ mod tests {
fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid {
Bid {
slot_id: slot_id.to_owned(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price,
currency: "USD".to_owned(),
creative: None,
@@ -1049,6 +1052,11 @@ mod tests {
provider_responses: vec![provider_success, provider_no_bid, provider_error],
mediator_response: None,
winning_bids: HashMap::from([("slot-1".to_owned(), winning)]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 99,
metadata: HashMap::new(),
};
@@ -1112,6 +1120,11 @@ mod tests {
provider_responses: vec![provider_success.clone()],
mediator_response: None,
winning_bids: HashMap::from([("slot-1".to_owned(), provider_success.bids[0].clone())]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 42,
metadata: HashMap::new(),
};
@@ -1153,6 +1166,11 @@ mod tests {
provider_responses: vec![provider_http_error],
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 12,
metadata: HashMap::new(),
};
@@ -1191,6 +1209,11 @@ mod tests {
provider_responses: vec![provider_success],
mediator_response: Some(mediator_response),
winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 80,
metadata: HashMap::new(),
};
@@ -1231,6 +1254,11 @@ mod tests {
provider_responses: Vec::new(),
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 1,
metadata: HashMap::new(),
};
diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs
index f61334787..271cac8ca 100644
--- a/crates/trusted-server-core/src/auction/types.rs
+++ b/crates/trusted-server-core/src/auction/types.rs
@@ -1,9 +1,15 @@
//! Core types for auction requests and responses.
+use base64::{
+ Engine as _,
+ engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD},
+};
use edgezero_core::body::Body as EdgeBody;
use http::Request;
+use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize};
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
+use url::Url;
use crate::auction::context::ContextValue;
use crate::geo::GeoInfo;
@@ -14,6 +20,42 @@ fn is_zero(value: &usize) -> bool {
*value == 0
}
+/// Injectable CSPRNG boundary for server-minted response-local identities.
+pub(crate) trait AuctionIdentityGenerator: Send + Sync {
+ /// Fill the complete destination or report that secure randomness is unavailable.
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()>;
+}
+
+/// Production CSPRNG for server-minted auction identities.
+pub(crate) struct SystemAuctionIdentityGenerator;
+
+impl AuctionIdentityGenerator for SystemAuctionIdentityGenerator {
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()> {
+ OsRng.try_fill_bytes(destination).map_err(|_| ())
+ }
+}
+
+/// Mint one response-unique unpadded base64url identity.
+pub(crate) fn mint_response_unique_base64url_identity(
+ generator: &dyn AuctionIdentityGenerator,
+ issued: &mut HashSet,
+ prefix: &str,
+ random_byte_count: usize,
+ collision_retries: usize,
+) -> Option {
+ for _ in 0..=collision_retries {
+ let mut bytes = vec![0_u8; random_byte_count];
+ if generator.fill(&mut bytes).is_err() {
+ return None;
+ }
+ let identity = format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes));
+ if issued.insert(identity.clone()) {
+ return Some(identity);
+ }
+ }
+ None
+}
+
/// Represents a unified auction request across all providers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuctionRequest {
@@ -154,6 +196,353 @@ pub struct AuctionContext<'a> {
pub services: &'a RuntimeServices,
}
+/// Closed, local reason set for rejecting provider bids or undeliverable winners.
+///
+/// These values are serialized only into existing auction debug/diagnostic
+/// surfaces. They are not a persistence or external-event taxonomy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AuctionDropReason {
+ /// Configured processing rejected an ordinary creative's only render source.
+ CreativeProcessingRejected,
+ /// Optional creative ID is present with an invalid type or value.
+ InvalidCreativeId,
+ /// Optional creative ID exceeds its UTF-8 byte bound.
+ CreativeIdTooLarge,
+ /// A positive integral dimension exceeds the supported range.
+ DimensionsOutOfRange,
+ /// An otherwise valid upstream bid ID is repeated in one provider response.
+ DuplicateUpstreamBidId,
+ /// A response contains no seat bids.
+ #[serde(rename = "empty_seatbid")]
+ EmptySeatBid,
+ /// A seat bid contains no usable bid array.
+ #[serde(rename = "empty_seatbid_bids")]
+ EmptySeatBidBids,
+ /// A creative URL is malformed, unsafe, or self-origin.
+ InvalidCreativeUrl,
+ /// A dimension is missing, malformed, nonpositive, or not requested.
+ InvalidDimensions,
+ /// A price is missing, malformed, nonfinite, or negative.
+ InvalidPrice,
+ /// The provider response violates the response-level contract.
+ InvalidProviderResponse,
+ /// The APS tag type is missing or unsupported.
+ InvalidTagType,
+ /// An upstream bid ID contains a forbidden control value or has the wrong type.
+ InvalidUpstreamBidId,
+ /// A valid sibling was preferred by deterministic per-slot reduction.
+ LostToHigherBid,
+ /// A provider bid is not an object.
+ MalformedBid,
+ /// APS creative metadata does not contain `creativeurl`.
+ MissingCreativeUrl,
+ /// Provider parsing was invoked without its request-local context.
+ MissingRequestContext,
+ /// A required upstream bid ID is absent or empty.
+ MissingUpstreamBidId,
+ /// A winner carries more than one render source.
+ MultipleRenderSources,
+ /// A winner has no render source.
+ NoRenderSource,
+ /// A typed renderer extension could not be serialized.
+ RendererExtensionSerializationFailed,
+ /// A validated renderer projection exceeds its bound.
+ RenderPayloadTooLarge,
+ /// APS script rendering is disabled by configuration.
+ ScriptRenderingDisabled,
+ /// A provider bid references an impression that was not dispatched.
+ UnknownImpression,
+ /// A provider bid declares a non-banner media type.
+ UnsupportedMediaType,
+ /// An upstream bid ID exceeds 64 UTF-8 bytes.
+ UpstreamBidIdTooLarge,
+}
+
+impl AuctionDropReason {
+ /// Return the exact existing debug/projection literal.
+ ///
+ /// This hand-written mapping also drives [`Ord`] so serialized-map output stays
+ /// alphabetically stable even when declaration order changes.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::CreativeProcessingRejected => "creative_processing_rejected",
+ Self::InvalidCreativeId => "invalid_creative_id",
+ Self::CreativeIdTooLarge => "creative_id_too_large",
+ Self::DimensionsOutOfRange => "dimensions_out_of_range",
+ Self::DuplicateUpstreamBidId => "duplicate_upstream_bid_id",
+ Self::EmptySeatBid => "empty_seatbid",
+ Self::EmptySeatBidBids => "empty_seatbid_bids",
+ Self::InvalidCreativeUrl => "invalid_creative_url",
+ Self::InvalidDimensions => "invalid_dimensions",
+ Self::InvalidPrice => "invalid_price",
+ Self::InvalidProviderResponse => "invalid_provider_response",
+ Self::InvalidTagType => "invalid_tag_type",
+ Self::InvalidUpstreamBidId => "invalid_upstream_bid_id",
+ Self::LostToHigherBid => "lost_to_higher_bid",
+ Self::MalformedBid => "malformed_bid",
+ Self::MissingCreativeUrl => "missing_creative_url",
+ Self::MissingRequestContext => "missing_request_context",
+ Self::MissingUpstreamBidId => "missing_upstream_bid_id",
+ Self::MultipleRenderSources => "multiple_render_sources",
+ Self::NoRenderSource => "no_render_source",
+ Self::RendererExtensionSerializationFailed => "renderer_extension_serialization_failed",
+ Self::RenderPayloadTooLarge => "render_payload_too_large",
+ Self::ScriptRenderingDisabled => "script_rendering_disabled",
+ Self::UnknownImpression => "unknown_impression",
+ Self::UnsupportedMediaType => "unsupported_media_type",
+ Self::UpstreamBidIdTooLarge => "upstream_bid_id_too_large",
+ }
+ }
+}
+
+impl Ord for AuctionDropReason {
+ fn cmp(&self, other: &Self) -> core::cmp::Ordering {
+ self.as_str().cmp(other.as_str())
+ }
+}
+
+impl PartialOrd for AuctionDropReason {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+/// Typed counts projected into the existing `drop_reasons` debug object.
+pub type AuctionDropReasons = BTreeMap;
+
+/// Increment one typed local drop reason.
+pub(crate) fn record_auction_drop(reasons: &mut AuctionDropReasons, reason: AuctionDropReason) {
+ *reasons.entry(reason).or_default() += 1;
+}
+
+/// Closed failure set for one requested slot's server-auction decision.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AuctionSlotFailureReason {
+ /// The auction orchestrator is disabled.
+ AuctionDisabled,
+ /// Request consent does not permit a server-side auction.
+ ConsentDenied,
+ /// No enabled configured provider can bid on the slot.
+ SlotNotEligible,
+ /// A dispatched provider exceeded its deadline.
+ ProviderTimeout,
+ /// A provider could not launch or complete its transport/HTTP exchange.
+ ProviderError,
+ /// A provider response failed structural, currency, identity, or bid validation.
+ InvalidProviderResponse,
+ /// The configured mediator failed or returned invalid provenance.
+ MediationFailed,
+ /// A selected candidate cannot be represented by the exact browser contract.
+ WinnerNotRenderable,
+ /// A unique renderer reservation could not be minted.
+ IdentityGenerationFailed,
+ /// An internal invariant or candidate-identity operation failed.
+ InternalError,
+}
+
+impl AuctionSlotFailureReason {
+ /// Return the exact wire literal.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::AuctionDisabled => "auction_disabled",
+ Self::ConsentDenied => "consent_denied",
+ Self::SlotNotEligible => "slot_not_eligible",
+ Self::ProviderTimeout => "provider_timeout",
+ Self::ProviderError => "provider_error",
+ Self::InvalidProviderResponse => "invalid_provider_response",
+ Self::MediationFailed => "mediation_failed",
+ Self::WinnerNotRenderable => "winner_not_renderable",
+ Self::IdentityGenerationFailed => "identity_generation_failed",
+ Self::InternalError => "internal_error",
+ }
+ }
+
+ /// Closed multi-provider aggregation priority; lower values win.
+ #[must_use]
+ pub const fn priority(self) -> u8 {
+ match self {
+ Self::InternalError => 0,
+ Self::MediationFailed => 1,
+ Self::InvalidProviderResponse => 2,
+ Self::ProviderError => 3,
+ Self::ProviderTimeout => 4,
+ Self::ConsentDenied => 5,
+ Self::AuctionDisabled => 6,
+ Self::SlotNotEligible => 7,
+ Self::WinnerNotRenderable | Self::IdentityGenerationFailed => u8::MAX,
+ }
+ }
+}
+
+/// Exactly one final server-auction decision for a requested slot.
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+#[serde(
+ tag = "outcome",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase",
+ deny_unknown_fields
+)]
+pub enum SlotAuctionDecisionV1 {
+ /// A candidate won and joins exactly one projected bid.
+ Winner {
+ /// Exact request slot identifier.
+ slot: String,
+ /// Opaque response-local candidate identifier.
+ candidate_id: String,
+ },
+ /// Every dispatched provider completed successfully without a candidate.
+ NoBid {
+ /// Exact request slot identifier.
+ slot: String,
+ },
+ /// The slot failed with one closed reason.
+ Failed {
+ /// Exact request slot identifier.
+ slot: String,
+ /// Exact failure reason.
+ reason: AuctionSlotFailureReason,
+ },
+}
+
+impl SlotAuctionDecisionV1 {
+ /// Return the exact slot identifier shared by every variant.
+ #[must_use]
+ pub fn slot(&self) -> &str {
+ match self {
+ Self::Winner { slot, .. } | Self::NoBid { slot } | Self::Failed { slot, .. } => slot,
+ }
+ }
+}
+
+impl Serialize for SlotAuctionDecisionV1 {
+ fn serialize(&self, serializer: S) -> Result
+ where
+ S: serde::Serializer,
+ {
+ use serde::ser::SerializeStruct;
+
+ match self {
+ Self::Winner { slot, candidate_id } => {
+ let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?;
+ state.serialize_field("slot", slot)?;
+ state.serialize_field("outcome", "winner")?;
+ state.serialize_field("candidateId", candidate_id)?;
+ state.end()
+ }
+ Self::NoBid { slot } => {
+ let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 2)?;
+ state.serialize_field("slot", slot)?;
+ state.serialize_field("outcome", "no_bid")?;
+ state.end()
+ }
+ Self::Failed { slot, reason } => {
+ let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?;
+ state.serialize_field("slot", slot)?;
+ state.serialize_field("outcome", "failed")?;
+ state.serialize_field("reason", reason)?;
+ state.end()
+ }
+ }
+ }
+}
+
+/// Ordered version-1 decision set for one server auction.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct AuctionDecisionSetV1 {
+ /// Contract version.
+ pub version: u8,
+ /// Exact auction identifier.
+ pub auction_id: String,
+ /// Exactly one decision per requested slot, in request order.
+ pub results: Vec,
+}
+
+impl AuctionDecisionSetV1 {
+ /// Construct an ordered decision set for a request-wide gate.
+ #[must_use]
+ pub fn failed(request: &AuctionRequest, reason: AuctionSlotFailureReason) -> Self {
+ Self {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: request
+ .slots
+ .iter()
+ .map(|slot| SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason,
+ })
+ .collect(),
+ }
+ }
+}
+
+/// Maximum canonical UTF-8 size of the browser auction projection.
+pub const MAX_BROWSER_AUCTION_PROJECTION_BYTES: usize = 8 * 1024 * 1024;
+/// Maximum number of requested results or projected winner bids.
+pub const MAX_BROWSER_AUCTION_RESULTS: usize = 256;
+/// Maximum number of publisher targeting entries on one projected bid.
+pub const MAX_BROWSER_AUCTION_TARGETING_ENTRIES: usize = 32;
+
+/// One exact browser-facing winner projection.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct BrowserAuctionBidV1 {
+ /// Response-local mediator candidate identity.
+ pub candidate_id: String,
+ /// Exact requested server slot identity.
+ pub slot: String,
+ /// Canonical provider integration name.
+ pub provider: String,
+ /// Exact provider-native upstream bid identity.
+ pub upstream_bid_id: String,
+ /// Selected finite, nonnegative CPM.
+ pub cpm: f64,
+ /// Exact auction currency; version 1 admits only `USD`.
+ pub currency: String,
+ /// Lexically ordered publisher targeting, excluding runtime-owned `hb_adid`.
+ pub targeting: BTreeMap,
+ /// Server-minted renderer capability identity for APS/ADM only.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub renderer_reservation_id: Option,
+ /// Sole tagged render authority for the winner.
+ pub render_source: BidRenderSourceV1,
+}
+
+/// Exact GAM placement metadata required to publish one server-projected slot.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct BrowserAuctionSlotV1 {
+ /// Exact server slot identity joined to one auction decision.
+ pub slot: String,
+ /// Fully rendered GAM ad-unit path for this navigation.
+ pub gam_unit_path: String,
+ /// Stable configured DOM id/prefix for responsive resolution.
+ pub div_id: String,
+ /// Accepted banner dimensions in configured order.
+ pub formats: Vec<[u32; 2]>,
+ /// Static publisher targeting applied before winner targeting.
+ pub targeting: BTreeMap,
+}
+
+/// Complete browser-facing version-1 auction projection.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct BrowserAuctionProjectionV1 {
+ /// Contract version.
+ pub version: u8,
+ /// Ordered decision set for every requested slot.
+ pub auction: AuctionDecisionSetV1,
+ /// Ordered GAM placement definitions; empty only for direct `/auction` serialization.
+ pub slots: Vec,
+ /// Winner bids in matching decision order.
+ pub bids: Vec,
+}
+
/// URL used by the orchestrator when invoking a mediator from the collect
/// path. Providers can `debug_assert` against this value to catch a mediator
/// that has accidentally started depending on `context.request` carrying real
@@ -187,7 +576,7 @@ pub enum ApsTagType {
/// Version 1 APS renderer descriptor shared with browser clients.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ApsRendererV1 {
/// Renderer contract version.
pub version: u8,
@@ -210,22 +599,308 @@ pub struct ApsRendererV1 {
pub height: u32,
}
-/// Typed browser renderer capability carried by a bid.
+/// Version 1 inline ADM render source shared with browser clients.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct AdmRenderSourceV1 {
+ /// Render-source contract version.
+ pub version: u8,
+ /// Exact creative markup.
+ pub adm: String,
+ /// Creative width.
+ pub width: u32,
+ /// Creative height.
+ pub height: u32,
+}
+
+/// Thin version 1 carrier for the current GPT-owned PBS Cache behavior.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct BaselinePbsCacheSourceV1 {
+ /// Render-source contract version.
+ pub version: u8,
+ /// Exact native PBS Cache identity.
+ pub cache_id: String,
+ /// Exact current-main `hb_cache_host` value.
+ pub cache_host: String,
+ /// Exact current-main `hb_cache_path` value.
+ pub cache_path: String,
+ /// Winning width transported without cache-specific validation.
+ pub width: u32,
+ /// Winning height transported without cache-specific validation.
+ pub height: u32,
+}
+
+/// Typed browser render source carried by a bid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "lowercase")]
-pub enum BidRenderer {
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum BidRenderSourceV1 {
/// APS renderer version 1.
Aps(ApsRendererV1),
+ /// Inline ADM version 1.
+ Adm(AdmRenderSourceV1),
+ /// Current-main GPT-owned PBS Cache carrier.
+ PbsCache(BaselinePbsCacheSourceV1),
}
-impl BidRenderer {
+impl BidRenderSourceV1 {
/// Return the APS renderer descriptor when this is an APS renderer.
#[must_use]
pub fn as_aps(&self) -> Option<&ApsRendererV1> {
match self {
Self::Aps(renderer) => Some(renderer),
+ Self::Adm(_) | Self::PbsCache(_) => None,
+ }
+ }
+}
+
+/// Smallest accepted renderer dimension in CSS pixels.
+pub const RENDER_DIMENSION_MIN: u64 = 1;
+/// Largest accepted renderer dimension in CSS pixels.
+pub const RENDER_DIMENSION_MAX: u64 = 4096;
+
+const MAX_APS_ACCOUNT_ID_BYTES: usize = 1024;
+const MAX_APS_BID_ID_BYTES: usize = 64;
+const MAX_APS_CREATIVE_ID_BYTES: usize = 1024;
+const MAX_APS_CREATIVE_URL_BYTES: usize = 4096;
+const MAX_APS_RENDER_ENVELOPE_BYTES: usize = 256 * 1024;
+const MAX_APS_RENDER_ENVELOPE_BASE64_BYTES: usize = 4 * MAX_APS_RENDER_ENVELOPE_BYTES.div_ceil(3);
+
+/// Cross-language APS descriptor validation result.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ApsRendererValidationResult {
+ /// Descriptor and decoded envelope are valid and agree.
+ Accepted,
+ /// Descriptor or decoded envelope is malformed.
+ DescriptorInvalid,
+ /// A dimension has the wrong type or is nonfinite, fractional, zero, or negative.
+ InvalidDimensions,
+ /// An otherwise integral positive dimension is outside the supported range.
+ DimensionsOutOfRange,
+}
+
+impl ApsRendererValidationResult {
+ /// Return the exact browser failure/result literal.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Accepted => "accepted",
+ Self::DescriptorInvalid => "descriptor_invalid",
+ Self::InvalidDimensions => "invalid_dimensions",
+ Self::DimensionsOutOfRange => "dimensions_out_of_range",
+ }
+ }
+}
+
+fn has_exact_json_keys(value: &serde_json::Value, expected: &[&str]) -> bool {
+ value.as_object().is_some_and(|object| {
+ object.len() == expected.len() && expected.iter().all(|key| object.contains_key(*key))
+ })
+}
+
+fn classify_render_dimension(value: &serde_json::Value) -> ApsRendererValidationResult {
+ let Some(number) = value.as_f64() else {
+ return ApsRendererValidationResult::InvalidDimensions;
+ };
+ if !number.is_finite() || number.fract() != 0.0 || number <= 0.0 {
+ return ApsRendererValidationResult::InvalidDimensions;
+ }
+ if number < RENDER_DIMENSION_MIN as f64 || number > RENDER_DIMENSION_MAX as f64 {
+ return ApsRendererValidationResult::DimensionsOutOfRange;
+ }
+ ApsRendererValidationResult::Accepted
+}
+
+fn valid_aps_creative_url(value: &str, publisher_origin: &str) -> bool {
+ if value.len() > MAX_APS_CREATIVE_URL_BYTES {
+ return false;
+ }
+ let Ok(url) = Url::parse(value) else {
+ return false;
+ };
+ url.scheme() == "https"
+ && url.host_str().is_some()
+ && url.username().is_empty()
+ && url.password().is_none()
+ && url.origin().ascii_serialization() != publisher_origin
+}
+
+/// Classify a raw APS renderer descriptor using the cross-language version-1 contract.
+#[must_use]
+pub fn classify_aps_renderer_v1(
+ value: &serde_json::Value,
+ publisher_origin: &str,
+) -> ApsRendererValidationResult {
+ const REQUIRED_KEYS: &[&str] = &[
+ "aaxResponse",
+ "accountId",
+ "bidId",
+ "creativeUrl",
+ "height",
+ "tagType",
+ "type",
+ "version",
+ "width",
+ ];
+ const KEYS_WITH_CREATIVE_ID: &[&str] = &[
+ "aaxResponse",
+ "accountId",
+ "bidId",
+ "creativeId",
+ "creativeUrl",
+ "height",
+ "tagType",
+ "type",
+ "version",
+ "width",
+ ];
+
+ if !has_exact_json_keys(value, REQUIRED_KEYS)
+ && !has_exact_json_keys(value, KEYS_WITH_CREATIVE_ID)
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let Some(descriptor) = value.as_object() else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if descriptor.get("type").and_then(serde_json::Value::as_str) != Some("aps")
+ || descriptor
+ .get("version")
+ .and_then(serde_json::Value::as_f64)
+ .is_none_or(|version| version != 1.0)
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let Some(account_id) = descriptor
+ .get("accountId")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ let Some(bid_id) = descriptor.get("bidId").and_then(serde_json::Value::as_str) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if account_id.is_empty()
+ || account_id.len() > MAX_APS_ACCOUNT_ID_BYTES
+ || bid_id.is_empty()
+ || bid_id.len() > MAX_APS_BID_ID_BYTES
+ || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f)
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ if let Some(creative_id) = descriptor.get("creativeId") {
+ let Some(creative_id) = creative_id.as_str() else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if creative_id.is_empty() || creative_id.len() > MAX_APS_CREATIVE_ID_BYTES {
+ return ApsRendererValidationResult::DescriptorInvalid;
}
}
+ let Some(tag_type) = descriptor
+ .get("tagType")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if tag_type != "iframe" && tag_type != "script" {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let width_result =
+ classify_render_dimension(descriptor.get("width").unwrap_or(&serde_json::Value::Null));
+ if width_result != ApsRendererValidationResult::Accepted {
+ return width_result;
+ }
+ let height_result =
+ classify_render_dimension(descriptor.get("height").unwrap_or(&serde_json::Value::Null));
+ if height_result != ApsRendererValidationResult::Accepted {
+ return height_result;
+ }
+
+ let Some(creative_url) = descriptor
+ .get("creativeUrl")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ let Some(aax_response) = descriptor
+ .get("aaxResponse")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if !valid_aps_creative_url(creative_url, publisher_origin)
+ || aax_response.is_empty()
+ || aax_response.len() > MAX_APS_RENDER_ENVELOPE_BASE64_BYTES
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Ok(decoded_bytes) = BASE64_STANDARD.decode(aax_response) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if decoded_bytes.len() > MAX_APS_RENDER_ENVELOPE_BYTES
+ || BASE64_STANDARD.encode(&decoded_bytes) != aax_response
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Ok(decoded_utf8) = core::str::from_utf8(&decoded_bytes) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ let Ok(decoded) = serde_json::from_str::(decoded_utf8) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if !has_exact_json_keys(&decoded, &["seatbid"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Some(seats) = decoded.get("seatbid").and_then(serde_json::Value::as_array) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if seats.len() != 1 || !has_exact_json_keys(&seats[0], &["bid"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Some(bids) = seats[0].get("bid").and_then(serde_json::Value::as_array) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if bids.len() != 1 || !has_exact_json_keys(&bids[0], &["ext", "h", "id", "price", "w"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let bid = &bids[0];
+ let Some(ext) = bid.get("ext") else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if !has_exact_json_keys(ext, &["creativeurl", "tagtype"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let bid_width_result =
+ classify_render_dimension(bid.get("w").unwrap_or(&serde_json::Value::Null));
+ if bid_width_result != ApsRendererValidationResult::Accepted {
+ return bid_width_result;
+ }
+ let bid_height_result =
+ classify_render_dimension(bid.get("h").unwrap_or(&serde_json::Value::Null));
+ if bid_height_result != ApsRendererValidationResult::Accepted {
+ return bid_height_result;
+ }
+ let price_is_valid = bid
+ .get("price")
+ .and_then(serde_json::Value::as_f64)
+ .is_some_and(|price| price.is_finite() && price >= 0.0);
+ if bid.get("id").and_then(serde_json::Value::as_str) != Some(bid_id)
+ || bid.get("w").and_then(serde_json::Value::as_f64)
+ != descriptor.get("width").and_then(serde_json::Value::as_f64)
+ || bid.get("h").and_then(serde_json::Value::as_f64)
+ != descriptor.get("height").and_then(serde_json::Value::as_f64)
+ || ext.get("creativeurl").and_then(serde_json::Value::as_str) != Some(creative_url)
+ || ext.get("tagtype").and_then(serde_json::Value::as_str) != Some(tag_type)
+ || !price_is_valid
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ ApsRendererValidationResult::Accepted
}
/// Individual bid from a provider.
@@ -233,13 +908,22 @@ impl BidRenderer {
pub struct Bid {
/// Slot this bid is for
pub slot_id: String,
+ /// Server-minted opaque identifier used only for this auction response.
+ #[serde(skip)]
+ pub candidate_id: Option,
+ /// Provider integration name paired with the upstream bid ID for provenance.
+ #[serde(skip)]
+ pub candidate_provider: Option,
+ /// Server-minted renderer capability identifier (populated during projection).
+ #[serde(skip)]
+ pub renderer_reservation_id: Option,
/// Bid price in CPM.
pub price: Option,
/// Currency code (e.g., "USD")
pub currency: String,
/// Creative markup (HTML/VAST).
///
- /// `None` when the bid uses a typed [`BidRenderer`] instead.
+ /// `None` when the bid uses a typed [`BidRenderSourceV1`] instead.
pub creative: Option,
/// Advertiser domain
pub adomain: Option>,
@@ -267,7 +951,7 @@ pub struct Bid {
pub creative_id: Option,
/// Typed browser renderer capability.
#[serde(skip_serializing_if = "Option::is_none")]
- pub renderer: Option,
+ pub renderer: Option,
/// Prebid Cache UUID for this bid.
///
/// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response.
@@ -288,6 +972,28 @@ pub struct Bid {
pub metadata: HashMap,
}
+/// Length of the hex-encoded creative trace hash.
+const ADM_TRACE_HASH_LEN: usize = 16;
+
+/// Compute the trace hash for delivered creative markup.
+#[must_use]
+pub fn adm_trace_hash(adm: &str) -> String {
+ use sha2::{Digest as _, Sha256};
+
+ let digest = Sha256::digest(adm.as_bytes());
+ let mut hex = hex::encode(digest);
+ hex.truncate(ADM_TRACE_HASH_LEN);
+ hex
+}
+
+impl Bid {
+ /// Trace hash of this bid's creative markup, when present.
+ #[must_use]
+ pub fn creative_trace_hash(&self) -> Option {
+ self.creative.as_deref().map(adm_trace_hash)
+ }
+}
+
/// Per-provider summary included in the auction response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderSummary {
@@ -338,7 +1044,7 @@ pub struct OrchestratorExt {
pub dropped_winner_count: usize,
/// Machine-readable reasons for omitted winners.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub dropped_winner_reasons: BTreeMap,
+ pub dropped_winner_reasons: AuctionDropReasons,
}
/// Status of bid response.
@@ -394,6 +1100,30 @@ impl AuctionResponse {
self.metadata.insert(key.into(), value);
self
}
+
+ /// Project typed local drop reasons into the existing provider metadata surface.
+ #[must_use]
+ pub fn with_drop_reasons(mut self, reasons: &AuctionDropReasons) -> Self {
+ if !reasons.is_empty() {
+ let values = reasons
+ .iter()
+ .map(|(reason, count)| {
+ (reason.as_str().to_string(), serde_json::Value::from(*count))
+ })
+ .collect();
+ self.metadata.insert(
+ "drop_reasons".to_string(),
+ serde_json::Value::Object(values),
+ );
+ }
+ self
+ }
+
+ /// Project one typed local drop reason into provider metadata.
+ #[must_use]
+ pub fn with_drop_reason(self, reason: AuctionDropReason) -> Self {
+ self.with_drop_reasons(&BTreeMap::from([(reason, 1)]))
+ }
}
#[cfg(test)]
@@ -401,9 +1131,59 @@ mod tests {
use super::*;
use serde_json::json;
+ #[test]
+ fn typed_drop_reasons_use_exact_literals_in_provider_summary_metadata() {
+ let reasons = [
+ AuctionDropReason::CreativeProcessingRejected,
+ AuctionDropReason::InvalidCreativeId,
+ AuctionDropReason::CreativeIdTooLarge,
+ AuctionDropReason::DimensionsOutOfRange,
+ AuctionDropReason::DuplicateUpstreamBidId,
+ AuctionDropReason::EmptySeatBid,
+ AuctionDropReason::EmptySeatBidBids,
+ AuctionDropReason::InvalidCreativeUrl,
+ AuctionDropReason::InvalidDimensions,
+ AuctionDropReason::InvalidPrice,
+ AuctionDropReason::InvalidProviderResponse,
+ AuctionDropReason::InvalidTagType,
+ AuctionDropReason::InvalidUpstreamBidId,
+ AuctionDropReason::LostToHigherBid,
+ AuctionDropReason::MalformedBid,
+ AuctionDropReason::MissingCreativeUrl,
+ AuctionDropReason::MissingRequestContext,
+ AuctionDropReason::MissingUpstreamBidId,
+ AuctionDropReason::MultipleRenderSources,
+ AuctionDropReason::NoRenderSource,
+ AuctionDropReason::RendererExtensionSerializationFailed,
+ AuctionDropReason::RenderPayloadTooLarge,
+ AuctionDropReason::ScriptRenderingDisabled,
+ AuctionDropReason::UnknownImpression,
+ AuctionDropReason::UnsupportedMediaType,
+ AuctionDropReason::UpstreamBidIdTooLarge,
+ ];
+ for reason in reasons {
+ assert_eq!(
+ serde_json::to_value(reason).expect("drop reason should serialize"),
+ json!(reason.as_str()),
+ "serde and diagnostic literal should agree for {reason:?}"
+ );
+ }
+
+ let response = AuctionResponse::no_bid("aps", 12)
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
+ let summary = ProviderSummary::from(&response);
+ assert_eq!(
+ summary.metadata["drop_reasons"]["invalid_provider_response"], 1,
+ "publisher provider-summary projection should retain the typed reason"
+ );
+ }
+
fn make_bid(bidder: &str) -> Bid {
Bid {
slot_id: "slot-1".to_owned(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(1.0),
currency: "USD".to_owned(),
creative: None,
@@ -536,6 +1316,9 @@ mod tests {
fn bid_with_cache_fields_round_trips_through_json() {
let bid = Bid {
slot_id: "atf".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(1.50),
currency: "USD".to_string(),
creative: None,
@@ -575,7 +1358,7 @@ mod tests {
#[test]
fn aps_renderer_serializes_to_versioned_camel_case_contract() {
- let renderer = BidRenderer::Aps(ApsRendererV1 {
+ let renderer = BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account-id".to_string(),
bid_id: "fictional-bid-id".to_string(),
@@ -609,7 +1392,7 @@ mod tests {
#[test]
fn aps_renderer_omits_absent_creative_id() {
- let renderer = BidRenderer::Aps(ApsRendererV1 {
+ let renderer = BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account-id".to_string(),
bid_id: "fictional-bid-id".to_string(),
@@ -638,10 +1421,40 @@ mod tests {
);
}
+ #[test]
+ fn slot_failure_priority_matches_the_closed_contract() {
+ let ordered = [
+ AuctionSlotFailureReason::InternalError,
+ AuctionSlotFailureReason::MediationFailed,
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ AuctionSlotFailureReason::ProviderError,
+ AuctionSlotFailureReason::ProviderTimeout,
+ AuctionSlotFailureReason::ConsentDenied,
+ AuctionSlotFailureReason::AuctionDisabled,
+ AuctionSlotFailureReason::SlotNotEligible,
+ ];
+
+ assert_eq!(
+ ordered.map(AuctionSlotFailureReason::priority),
+ [0, 1, 2, 3, 4, 5, 6, 7]
+ );
+ assert_eq!(
+ AuctionSlotFailureReason::WinnerNotRenderable.priority(),
+ u8::MAX
+ );
+ assert_eq!(
+ AuctionSlotFailureReason::IdentityGenerationFailed.priority(),
+ u8::MAX
+ );
+ }
+
#[test]
fn bid_has_ad_id_field() {
let bid = Bid {
slot_id: "s".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(1.0),
currency: "USD".to_string(),
creative: None,
diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs
index 8e70aa020..a58cf5561 100644
--- a/crates/trusted-server-core/src/auth.rs
+++ b/crates/trusted-server-core/src/auth.rs
@@ -269,9 +269,8 @@ mod tests {
/// handler covers is the operator's decision, and silently carving holes in
/// it would be worse than a documented constraint. Operators must scope
/// handler patterns to the paths they mean (`^/_ts/admin`) — see the
- /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps
- /// affected deployments serving SPA ads until they do, but it disappears
- /// with the alias in IABTechLab/trusted-server#970.
+ /// configuration guide. A broad pattern will block the canonical page-bids
+ /// endpoint; the hard-cutover client does not retry a compatibility alias.
#[test]
fn broad_handler_regex_also_covers_browser_facing_endpoints() {
let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#);
diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs
index e1152b1e7..828311f12 100644
--- a/crates/trusted-server-core/src/constants.rs
+++ b/crates/trusted-server-core/src/constants.rs
@@ -5,6 +5,7 @@ pub const COOKIE_TS_EC: &str = "ts-ec";
/// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers.
pub const COOKIE_TS_EIDS: &str = "ts-eids";
pub const COOKIE_TS_TESTER: &str = "ts-tester";
+pub const COOKIE_TS_TRACE: &str = "ts-trace";
pub const COOKIE_SHAREDID: &str = "sharedId";
pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id");
diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs
index a4d641bdf..4e912a03d 100644
--- a/crates/trusted-server-core/src/creative.rs
+++ b/crates/trusted-server-core/src/creative.rs
@@ -574,8 +574,8 @@ fn process_auction_creative_with_rewriter(
/// - 1x1 ` ` pixels → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>`
/// - Non-pixel absolute images → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>`
/// - ` hello");
- compressed.extend(gzip_encode(b"` that triggers bid injection lives in the SECOND gzip
- // member. `flate2::read::GzDecoder` decodes only the first member, so
- // this buffered `Body::Once` body (the non-stream auction arm) proves
- // the multi-member decoder now reads every member.
- let mut compressed = gzip_encode(b"