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..9de1f8952 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,23 @@ on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
+ inputs:
+ base_sha:
+ description: Exact rc/202608 base commit for performance comparison
+ required: true
+ type: string
+ 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 +38,32 @@ 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:
+ base_sha: ${{ inputs.base_sha }}
+ 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 +100,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 +160,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 +197,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 +339,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 58e6a4ac7..36b30e703 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:
@@ -247,6 +259,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
@@ -267,5 +281,98 @@ 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: Verify APS/TSJS gate scripts
+ working-directory: ${{ github.workspace }}
+ run: bash scripts/ci/aps-tsjs-quality.sh contracts
+
+ - 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..f2e9535ec
--- /dev/null
+++ b/.github/workflows/tsjs-performance-gate.yml
@@ -0,0 +1,137 @@
+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:
+ base_sha:
+ description: Exact pull-request base commit reachable from origin/rc/202608
+ required: true
+ type: string
+ 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:
+ base_sha:
+ description: Exact pull-request base commit reachable from origin/rc/202608
+ required: true
+ type: string
+ 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 }}
+ TSJS_PERF_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || inputs.base_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: 50
+ 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
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+
+ - 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 rc baseline artifacts
+ shell: bash
+ run: bash scripts/ci/tsjs-performance.sh build-baseline
+
+ - 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"
+ 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 32b027a47..e3b4b15a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,16 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Breaking** — Admin Basic-auth coverage now includes `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}`, and `GET /_ts/admin/eids`. Existing configurations whose `[[handlers]]` patterns protect only the key-management endpoints now fail startup; broaden coverage before deploying, preferably with a namespace-boundary pattern such as `^/_ts/admin(?:/|$)`. Coverage of the dynamic `/_ts/admin/ec/{id}` route is no longer inferred from ID-shaped samples: the router accepts any segment after `/_ts/admin/ec/` and Basic Auth runs on the raw path before routing, so patterns anchored to the EC ID grammar (for example `^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$`) are rejected in favor of a prefix-level matcher. Placeholder and well-known weak handler passwords (`changeme`, `password`, `admin`, `replace-with-…`) now fail startup on every handler rather than only on handlers inferred to cover an admin endpoint, because first-match-wins handler selection lets a narrow handler shadow the admin namespace.
- Publisher HTML uses the browser-only `Cache-Control: private, max-age=60` policy for successful GET document responses and their `304 Not Modified` revalidations when server-side ad templates are structurally inactive, while preserving origin `private`/`no-store` policies and request-scoped bot, prefetch, or consent-denied responses. The `private` directive prevents shared caches that use `Cache-Control` from storing the document. Cookie-bearing responses using the generated inactive policy are finalized as `private, max-age=0`; CDN-specific cache headers remain unchanged and continue to control supporting CDNs independently. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers; an absent configuration, an unmatched slot, or a disabled auction also make the stack structurally inactive. An explicit `enabled = false` is not compatible with older binaries: restore the default, re-push and finalize the config before rolling back.
-- **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.
-- 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** — 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.
- **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
@@ -30,10 +29,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 65b1d9507..d028a4876 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5402,6 +5402,7 @@ dependencies = [
"trusted-server-core",
"url",
"urlencoding",
+ "web-time",
]
[[package]]
@@ -5537,6 +5538,7 @@ dependencies = [
"reqwest 0.12.28",
"scraper",
"serde_json",
+ "tempfile",
"testcontainers",
"tokio",
"toml",
@@ -5554,6 +5556,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 4b71d07ce..855d27541 100644
--- a/crates/trusted-server-adapter-axum/src/app.rs
+++ b/crates/trusted-server-adapter-axum/src/app.rs
@@ -24,8 +24,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,
@@ -34,6 +34,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;
@@ -84,6 +85,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
// ---------------------------------------------------------------------------
@@ -191,7 +277,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, EdgeCacheHeader::SMaxageFallback);
}
@@ -274,6 +360,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,
@@ -298,7 +385,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];
-fn named_routes() -> [NamedRoute; 16] {
+fn named_routes() -> [NamedRoute; 17] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
@@ -359,6 +446,11 @@ fn named_routes() -> [NamedRoute; 16] {
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],
@@ -371,13 +463,12 @@ fn named_routes() -> [NamedRoute; 16] {
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",
@@ -450,6 +541,9 @@ fn named_route_handler(
handle_admin_eids_lookup(&partner_registry, &req)
}
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 ed199e6bf..013a40ca8 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())
}
@@ -80,16 +104,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"),
@@ -101,6 +118,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
@@ -212,39 +232,133 @@ async fn tsjs_route_prefix_is_handled_not_5xx() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn tsjs_route_matching_hash_uses_s_maxage_fallback() {
- let mut svc = make_service();
- let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]);
- let req = Request::builder()
- .method("GET")
- .uri(src)
- .body(AxumBody::empty())
- .expect("should build request");
+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");
- let resp = svc
- .ready()
- .await
- .expect("should be ready")
- .call(req)
- .await
- .expect("should respond");
+ 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/v2")
+ .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!(
- resp.status().as_u16(),
- 200,
- "matching TSJS hash should serve OK"
+ response
+ .headers()
+ .get("content-type")
+ .and_then(|value| value.to_str().ok()),
+ Some("text/html; charset=utf-8")
);
assert_eq!(
- resp.headers()
+ response
+ .headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
- Some("public, max-age=31536000, s-maxage=31536000, immutable"),
- "Axum adapter should render the portable s-maxage fallback"
- );
- assert!(
- resp.headers().get("surrogate-control").is_none(),
- "s-maxage fallback must not emit Fastly Surrogate-Control"
+ 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("TS APS Bootstrap Ready"));
+ assert!(body.contains("TS APS Bootstrap Configure"));
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(body.contains("data:text/html;charset=utf-8,"));
+ assert!(!body.contains("client.aps.amazon-adsystem.com"));
+
+ for (method, path, expected) in [
+ ("POST", "/integrations/aps/runner.js", 405),
+ ("TRACE", "/integrations/aps/renderer/v2", 405),
+ ("CONNECT", "/integrations/aps/renderer/v2", 405),
+ ("PROPFIND", "/integrations/aps/renderer/v2", 405),
+ ("GET", "/integrations/aps/renderer", 404),
+ ("GET", "/integrations/aps/renderer/v1", 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);
}
// ---------------------------------------------------------------------------
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 86ac86987..cae088aa5 100644
--- a/crates/trusted-server-adapter-cloudflare/src/app.rs
+++ b/crates/trusted-server-adapter-cloudflare/src/app.rs
@@ -27,14 +27,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;
@@ -123,6 +123,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
// ---------------------------------------------------------------------------
@@ -371,7 +412,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,
@@ -389,10 +430,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,
@@ -492,19 +530,12 @@ fn build_router(state: &Arc) -> RouterService {
.post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async {
Ok::(admin_key_management_not_supported())
})
- // Admin EC lookup routes. Registered explicitly (like the key
- // routes above) so they never fall through to the publisher
- // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth
- // coverage. The EC identity graph is Fastly KV backed, so this
- // adapter has no store to read.
.get("/_ts/admin/ec", |_ctx: RequestContext| async {
Ok::(admin_ec_lookup_not_supported())
})
.get("/_ts/admin/ec/{id}", |_ctx: RequestContext| async {
Ok::(admin_ec_lookup_not_supported())
})
- // Admin EIDs echo: pure request inspection (no KV), so this
- // adapter serves the real handler.
.get(
"/_ts/admin/eids",
make_handler(Arc::clone(&state), |s, _services, req| async move {
@@ -512,6 +543,15 @@ fn build_router(state: &Arc) -> RouterService {
handle_admin_eids_lookup(&partner_registry, &req)
}),
)
+ // 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 {
@@ -572,15 +612,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 {
@@ -594,10 +627,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 {
@@ -611,6 +642,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 fb498ce4e..77a4b908b 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,77 @@ 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/v2")
+ .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("TS APS Bootstrap Ready"));
+ assert!(body.contains("TS APS Bootstrap Configure"));
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(body.contains("data:text/html;charset=utf-8,"));
+ assert!(!body.contains("client.aps.amazon-adsystem.com"));
+
+ for (method, path, expected) in [
+ ("POST", "/integrations/aps/runner.js", 405),
+ ("TRACE", "/integrations/aps/renderer/v2", 405),
+ ("CONNECT", "/integrations/aps/renderer/v2", 405),
+ ("PROPFIND", "/integrations/aps/renderer/v2", 405),
+ ("GET", "/integrations/aps/renderer", 404),
+ ("GET", "/integrations/aps/renderer/v1", 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.
@@ -204,40 +294,32 @@ async fn tsjs_route_is_routed_not_5xx() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn tsjs_route_emits_cloudflare_cache_header_for_matching_hash() {
- let router = test_router();
- let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]);
- let req = request_builder()
- .method("GET")
- .uri(src)
- .body(edgezero_core::body::Body::empty())
- .expect("should build request");
-
- let resp = route(router, req).await;
+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!(
- resp.status().as_u16(),
- 200,
- "matching TSJS hash should serve OK"
- );
- assert_eq!(
- resp.headers()
- .get("cache-control")
- .and_then(|value| value.to_str().ok()),
- Some("public, max-age=31536000, immutable"),
- "browser cache policy should be immutable for matching TSJS hash"
- );
- assert_eq!(
- resp.headers()
- .get("cloudflare-cdn-cache-control")
- .and_then(|value| value.to_str().ok()),
- Some("max-age=31536000"),
- "Cloudflare adapter should emit the Cloudflare-specific edge header"
- );
- assert!(
- resp.headers().get("surrogate-control").is_none(),
- "Cloudflare adapter must not emit Fastly Surrogate-Control"
- );
+ 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.
@@ -256,16 +338,9 @@ fn all_explicit_routes_are_registered() {
("GET", "/_ts/admin/ec/{id}"),
("GET", "/_ts/admin/eids"),
("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"),
@@ -277,6 +352,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 47cc609b2..97b651661 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 }
@@ -31,6 +35,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 5e78a87ed..2dfcd87b1 100644
--- a/crates/trusted-server-adapter-fastly/src/app.rs
+++ b/crates/trusted-server-adapter-fastly/src/app.rs
@@ -29,6 +29,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`] |
@@ -125,9 +126,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,
@@ -138,6 +138,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::{
@@ -171,6 +172,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();
@@ -290,8 +310,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=")
}
// ---------------------------------------------------------------------------
@@ -635,6 +655,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
@@ -1054,6 +1075,7 @@ enum NamedRouteHandler {
Identify,
SetTester,
ClearTester,
+ TraceMode,
Auction,
PageBids,
FirstPartyProxy,
@@ -1154,6 +1176,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],
@@ -1166,15 +1193,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",
@@ -1302,18 +1326,17 @@ 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_per_request_services, build_state_from_settings,
- startup_error_router,
+ AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, TrustedServerApp,
+ build_state_from_settings, startup_error_router,
};
use base64::Engine as _;
use bytes::Bytes;
use edgezero_core::body::Body;
- use edgezero_core::context::RequestContext;
use edgezero_core::http::{Method, Response, StatusCode, header, request_builder};
use edgezero_core::key_value_store::NoopKvStore;
- use edgezero_core::params::PathParams;
use edgezero_core::router::RouterService;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Mutex;
@@ -1412,6 +1435,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"
@@ -1434,6 +1462,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"]
@@ -1448,34 +1481,101 @@ mod tests {
TrustedServerApp::routes_for_state(&state)
}
- #[test]
- fn per_request_services_register_the_fastly_template_assembler() {
+ #[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");
- let context = RequestContext::new(
- empty_request(Method::GET, "/article"),
- PathParams::default(),
- );
+ block_on(dispatch_reserved_for_state(&state, request))
+ .expect("APS family should be reserved")
+ }
- let services = build_per_request_services(&state, &context);
- let template = format!(
- "article{}",
- trusted_server_core::publisher::AD_ASSEMBLY_SEAM
+ #[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/v2"));
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.headers()[header::CONTENT_TYPE],
+ "text/html; charset=utf-8"
);
- let fragment = b"";
- let assembled = services
- .template_assembler()
- .assemble(template.as_bytes(), fragment)
- .expect("Fastly services should provide ESI assembly");
-
assert_eq!(
- assembled,
- template
- .replace(
- trusted_server_core::publisher::AD_ASSEMBLY_SEAM,
- std::str::from_utf8(fragment).expect("fragment should be UTF-8")
- )
- .into_bytes()
+ 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("TS APS Bootstrap Ready"));
+ assert!(body.contains("TS APS Bootstrap Configure"));
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(body.contains("data:text/html;charset=utf-8,"));
+ 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/v2",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ Method::CONNECT,
+ "/integrations/aps/renderer/v2",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"),
+ "/integrations/aps/renderer/v2",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ Method::GET,
+ "/integrations/aps/renderer",
+ StatusCode::NOT_FOUND,
+ ),
+ (
+ Method::GET,
+ "/integrations/aps/renderer/v1",
+ 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
@@ -1611,18 +1711,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"
);
}
@@ -1751,83 +1851,29 @@ mod tests {
}
#[test]
- fn admin_ec_lookup_routes_are_registered() {
- // Both lookup shapes must be explicitly routed to the admin EC
- // handler: the bare cookie-based route and the parameterized route.
- // Leaving either unrouted would fall through to the publisher
- // fallback, forwarding the caller's `Authorization` header to the
- // origin.
- for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] {
- let route = NAMED_ROUTES
- .iter()
- .find(|route| route.path == path)
- .unwrap_or_else(|| panic!("{path} must be a named route"));
- assert!(
- matches!(route.handler, NamedRouteHandler::AdminEcLookup),
- "{path} must map to the admin EC lookup handler"
- );
- assert_eq!(
- route.primary_methods,
- &[Method::GET],
- "{path} must have GET as its only primary method"
- );
- }
-
- let eids_route = NAMED_ROUTES
- .iter()
- .find(|route| route.path == "/_ts/admin/eids")
- .expect("should register /_ts/admin/eids as a named route");
- assert!(
- matches!(eids_route.handler, NamedRouteHandler::AdminEidsLookup),
- "/_ts/admin/eids must map to the admin EIDs lookup handler"
- );
- assert_eq!(
- eids_route.primary_methods,
- &[Method::GET],
- "/_ts/admin/eids must have GET as its only primary method"
- );
- }
-
- #[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]
@@ -2039,6 +2085,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 e9184ea71..ea3b41af9 100644
--- a/crates/trusted-server-adapter-fastly/src/main.rs
+++ b/crates/trusted-server-adapter-fastly/src/main.rs
@@ -174,7 +174,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),
}
@@ -193,7 +206,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 {
@@ -335,8 +353,7 @@ fn send_edgezero_response(
mut response: HttpResponse,
request_filter_effects: Option<&RequestFilterEffects>,
) {
- apply_terminal_response_effects(&mut response, request_filter_effects);
- crate::middleware::enforce_uncacheable_cache_privacy(&mut response);
+ apply_send_response_effects(&mut response, request_filter_effects);
let (parts, body) = response.into_parts();
@@ -365,6 +382,21 @@ fn send_edgezero_response(
}
}
+fn apply_send_response_effects(
+ response: &mut HttpResponse,
+ request_filter_effects: Option<&RequestFilterEffects>,
+) {
+ if response
+ .extensions()
+ .get::()
+ .is_some()
+ {
+ return;
+ }
+ apply_terminal_response_effects(response, request_filter_effects);
+ crate::middleware::enforce_uncacheable_cache_privacy(response);
+}
+
/// Apply every late response mutation, then restore privacy invariants before headers commit.
fn apply_terminal_response_effects(
response: &mut HttpResponse,
@@ -583,6 +615,21 @@ mod tests {
);
}
+ #[test]
+ fn exact_route_headers_bypass_terminal_cache_rewriting() {
+ let mut response = response_builder()
+ .header("cache-control", "no-store")
+ .body(EdgeBody::empty())
+ .expect("should build exact route response");
+ response
+ .extensions_mut()
+ .insert(trusted_server_core::platform::ExactResponseHeadersV1);
+
+ apply_send_response_effects(&mut response, None);
+
+ assert_eq!(response.headers()["cache-control"], "no-store");
+ }
+
#[test]
fn late_filter_effects_cannot_make_an_assembled_response_public() {
let mut response = response_builder()
diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs
index 638aed82b..21f0537c2 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,
};
use trusted_server_core::settings::TrustedClientIpConfig;
@@ -532,6 +537,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
@@ -546,6 +576,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 {
@@ -572,6 +647,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,
@@ -985,6 +1142,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 06bb1a15a..67bc9b52f 100644
--- a/crates/trusted-server-adapter-spin/src/app.rs
+++ b/crates/trusted-server-adapter-spin/src/app.rs
@@ -11,6 +11,8 @@ use error_stack::Report;
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
use trusted_server_core::cache_policy::EdgeCacheHeader;
+#[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::ec::admin::{
admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported,
@@ -26,14 +28,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;
@@ -55,11 +57,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
@@ -79,6 +96,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
// ---------------------------------------------------------------------------
@@ -148,7 +208,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];
-fn named_fallback_paths() -> [(&'static str, &'static [Method]); 16] {
+fn named_fallback_paths() -> [(&'static str, &'static [Method]); 17] {
[
("/.well-known/trusted-server.json", &[Method::GET]),
("/verify-signature", &[Method::POST]),
@@ -159,9 +219,10 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 16] {
("/_ts/admin/eids", &[Method::GET]),
("/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]),
@@ -581,6 +642,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| {
@@ -695,9 +771,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, EdgeCacheHeader::SMaxageFallback)
} else if state.registry.has_route(&method, &path) {
let mut ec_context = EcContext::default();
@@ -792,27 +866,13 @@ 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)
- // Admin EC lookup routes. Registered explicitly (like the key
- // routes above) so they never fall through to the publisher
- // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth
- // coverage. The EC identity graph is Fastly KV backed, so this
- // adapter has no store to read.
.get("/_ts/admin/ec", admin_ec_not_supported_handler)
.get("/_ts/admin/ec/{id}", admin_ec_not_supported_handler)
.get("/_ts/admin/eids", admin_eids_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)
@@ -823,6 +883,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 f75ea687e..1f0693987 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,77 @@ 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/v2")
+ .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("TS APS Bootstrap Ready"));
+ assert!(body.contains("TS APS Bootstrap Configure"));
+ assert!(body.contains("/integrations/aps/runner.js"));
+ assert!(body.contains("data:text/html;charset=utf-8,"));
+ assert!(!body.contains("client.aps.amazon-adsystem.com"));
+
+ for (method, path, expected) in [
+ ("POST", "/integrations/aps/runner.js", 405),
+ ("TRACE", "/integrations/aps/renderer/v2", 405),
+ ("CONNECT", "/integrations/aps/renderer/v2", 405),
+ ("PROPFIND", "/integrations/aps/renderer/v2", 405),
+ ("GET", "/integrations/aps/renderer", 404),
+ ("GET", "/integrations/aps/renderer/v1", 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!(
@@ -366,33 +456,32 @@ async fn tsjs_route_is_routed_not_5xx() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn tsjs_route_matching_hash_uses_s_maxage_fallback() {
- let router = test_router();
- let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]);
- let req = request_builder()
- .method("GET")
- .uri(src)
- .body(edgezero_core::body::Body::empty())
- .expect("should build request");
-
- let resp = route(router, req).await;
+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!(
- resp.status().as_u16(),
- 200,
- "matching TSJS hash should serve OK"
- );
- assert_eq!(
- resp.headers()
- .get("cache-control")
- .and_then(|value| value.to_str().ok()),
- Some("public, max-age=31536000, s-maxage=31536000, immutable"),
- "Spin adapter should render the portable s-maxage fallback"
- );
- assert!(
- resp.headers().get("surrogate-control").is_none(),
- "s-maxage fallback must not emit Fastly Surrogate-Control"
- );
+ 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)]
@@ -516,54 +605,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-cli/src/ad_templates/expected.rs b/crates/trusted-server-cli/src/ad_templates/expected.rs
index 9392963ff..9cf6aca02 100644
--- a/crates/trusted-server-cli/src/ad_templates/expected.rs
+++ b/crates/trusted-server-cli/src/ad_templates/expected.rs
@@ -152,7 +152,8 @@ mod tests {
.collect::>()
.join(", ");
let toml = format!(
- "gam_network_id = \"123\"\n\
+ "enabled = true\n\
+ gam_network_id = \"123\"\n\
\n\
[[slot]]\n\
id = \"atf\"\n\
@@ -202,7 +203,8 @@ mod tests {
#[test]
fn expected_slots_default_resolution_without_overrides() {
- let toml = "gam_network_id = \"42\"\n\
+ let toml = "enabled = true\n\
+ gam_network_id = \"42\"\n\
\n\
[[slot]]\n\
id = \"footer\"\n\
@@ -223,7 +225,8 @@ mod tests {
#[test]
fn expected_slots_render_section_templates_per_path() {
- let toml = "gam_network_id = \"99999\"\n\
+ let toml = "enabled = true\n\
+ gam_network_id = \"99999\"\n\
section_root = \"homepage\"\n\
\n\
[[slot]]\n\
@@ -258,7 +261,8 @@ mod tests {
// A `{section}` template that renders past GAM's 100-byte unit-path
// limit. The runtime omits this slot for the request path, so diagnostics
// must not match it against a truncated or otherwise different path.
- let toml = "gam_network_id = \"99999\"\n\
+ let toml = "enabled = true\n\
+ gam_network_id = \"99999\"\n\
section_root = \"homepage\"\n\
\n\
[[slot]]\n\
diff --git a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs
index cb11a0a0c..ac99f101d 100644
--- a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs
+++ b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs
@@ -625,7 +625,8 @@ mod tests {
}
fn news_config() -> CreativeOpportunitiesConfig {
- let toml = "gam_network_id = \"123\"\n\
+ let toml = "enabled = true\n\
+ gam_network_id = \"123\"\n\
\n\
[[slot]]\n\
id = \"atf\"\n\
diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs
index 0485214fa..105c02156 100644
--- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs
+++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs
@@ -1677,7 +1677,7 @@ mod tests {
#[test]
fn merge_refuses_to_change_policy_used_by_preserved_templates() {
let existing: CreativeOpportunitiesConfig = toml::from_str(
- "gam_network_id = \"123\"\nsection_root = \"home\"\nsection_segment = 0\n\
+ "enabled = true\ngam_network_id = \"123\"\nsection_root = \"home\"\nsection_segment = 0\n\
[[slot]]\nid = \"header\"\ndiv_id = \"ad-header\"\n\
gam_unit_path = \"/{network_id}/site/{section}\"\npage_patterns = [\"/\"]\n\
formats = [{ width = 728, height = 90 }]\n",
@@ -1757,7 +1757,7 @@ mod tests {
// rather than demand `--replace` (which would discard the hand-tuned
// slots it is preserving).
let existing: CreativeOpportunitiesConfig = toml::from_str(
- "gam_network_id = \"123\"\n\
+ "enabled = true\ngam_network_id = \"123\"\n\
[[slot]]\nid = \"header\"\ndiv_id = \"ad-header\"\n\
gam_unit_path = \"/{network_id}/site/{section}\"\npage_patterns = [\"/\"]\n\
formats = [{ width = 728, height = 90 }]\n",
@@ -1775,7 +1775,7 @@ mod tests {
#[test]
fn merge_preserves_an_explicit_segment_when_section_root_is_unset() {
let existing: CreativeOpportunitiesConfig = toml::from_str(
- "gam_network_id = \"123\"\nsection_segment = 1\n\
+ "enabled = true\ngam_network_id = \"123\"\nsection_segment = 1\n\
[[slot]]\nid = \"header\"\ndiv_id = \"ad-header\"\n\
gam_unit_path = \"/{network_id}/site/{section}\"\npage_patterns = [\"/\"]\n\
formats = [{ width = 728, height = 90 }]\n",
@@ -2096,7 +2096,7 @@ mod tests {
let config_path = temp.path().join("trusted-server.toml");
fs::write(
&config_path,
- "[creative_opportunities]\ngam_network_id = \"111\"\n",
+ "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n",
)
.expect("should write config");
// The requested URL redirects; slots are scraped from the final page.
@@ -2366,7 +2366,7 @@ mod tests {
fn update_slots_rejects_invalid_page_pattern_without_touching_config() {
let temp = TempDir::new().expect("should create temp dir");
let config_path = temp.path().join("trusted-server.toml");
- let original = "[creative_opportunities]\ngam_network_id = \"111\"\n";
+ let original = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n";
fs::write(&config_path, original).expect("should write config");
let collector = FakeCollector::new(collected_page_with_header_slot());
let mut out = Vec::new();
@@ -2473,7 +2473,7 @@ mod tests {
let config_path = temp.path().join("trusted-server.toml");
fs::write(
&config_path,
- "[creative_opportunities]\ngam_network_id = \"111\"\n",
+ "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n",
)
.expect("should write config");
let collector = FakeCollector::new(collected_page_with_header_slot());
@@ -2510,7 +2510,7 @@ mod tests {
let config_path = temp.path().join("trusted-server.toml");
fs::write(
&config_path,
- "[creative_opportunities]\ngam_network_id = \"111\"\n",
+ "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n",
)
.expect("should write config");
let collector = FakeCollector::new(collected_page_with_header_slot());
diff --git a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs
index 1c401cb3d..433a34b4d 100644
--- a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs
+++ b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs
@@ -526,6 +526,11 @@ pub(super) fn splice_creative_slots(
// retain positions from their source document, so anchor the whole subtree
// here to keep the parent, slots, and provider tables together.
creative.set_position(section_position);
+ if !had_section {
+ // The hard-cutover schema requires an explicit switch. A generated
+ // section is active by definition because the operator requested it.
+ creative["enabled"] = toml_edit::value(true);
+ }
if let Some(network_id) = keys.network_id {
creative["gam_network_id"] = toml_edit::value(network_id);
}
@@ -555,7 +560,13 @@ fn ensure_only_managed_fields_changed(before: &str, after: &str) -> CliResult<()
.get_mut("creative_opportunities")
.and_then(toml::Value::as_table_mut)
{
- for key in ["slot", "gam_network_id", "section_root", "section_segment"] {
+ for key in [
+ "slot",
+ "enabled",
+ "gam_network_id",
+ "section_root",
+ "section_segment",
+ ] {
creative.remove(key);
}
creative.is_empty()
@@ -808,13 +819,14 @@ slot_id = "sidebar"
}
fn existing_config(toml_str: &str) -> CreativeOpportunitiesConfig {
- toml::from_str::(toml_str).expect("valid creative config")
+ toml::from_str::(&format!("enabled = true\n{toml_str}"))
+ .expect("valid creative config")
}
#[test]
fn splice_replaces_slots_and_preserves_other_sections() {
let existing = "[publisher]\ndomain = \"x\"\n\n\
- [creative_opportunities]\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\
+ [creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\
[[creative_opportunities.slot]]\nid = \"old\"\ndiv_id = \"old\"\n\
gam_unit_path = \"/111/old\"\npage_patterns = [\"/\"]\n\
formats = [{ width = 300, height = 250 }]\n\n\
@@ -827,6 +839,10 @@ slot_id = "sidebar"
out.contains("gam_network_id = \"222\""),
"network id updated"
);
+ assert!(
+ out.contains("enabled = true"),
+ "hard-cutover enablement should be preserved"
+ );
assert!(!out.contains("id = \"old\""), "old slot removed");
assert!(
out.contains("gam_unit_path = \"/222/homepage/header\""),
@@ -947,7 +963,7 @@ slot_id = "sidebar"
#[test]
fn splice_rejects_top_level_inline_creative_opportunities_table() {
- let existing = "creative_opportunities = { gam_network_id = \"111\" }\n";
+ let existing = "creative_opportunities = { enabled = true, gam_network_id = \"111\" }\n";
let error = splice_creative_slots(existing, &network_keys("222"), &header_rendered())
.expect_err("should refuse a top-level inline table");
@@ -975,7 +991,7 @@ slot_id = "sidebar"
fn splice_inserts_section_policy_keys_a_config_does_not_have_yet() {
// The whole point of `upsert`: every config predating templating lacks
// these keys, so a replace-only writer could never add them.
- let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\n\
+ let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n\
[auction]\nenabled = true\n";
let out = splice_creative_slots(
@@ -999,7 +1015,7 @@ slot_id = "sidebar"
#[test]
fn splice_replaces_section_policy_keys_that_are_already_present() {
- let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\
+ let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\
section_root = \"old\"\nsection_segment = 2\n";
let out = splice_creative_slots(
@@ -1025,7 +1041,7 @@ slot_id = "sidebar"
// `section_root`/`section_segment` are `deny_unknown_fields` additions:
// writing them into a config that does not need them would make it
// unloadable by an older binary for no benefit.
- let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n";
+ let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n";
let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered())
.expect("should splice");
@@ -1087,6 +1103,11 @@ slot_id = "sidebar"
value["creative_opportunities"]["gam_network_id"].as_str(),
Some("222")
);
+ assert_eq!(
+ value["creative_opportunities"]["enabled"].as_bool(),
+ Some(true),
+ "a newly generated hard-cutover section must be loadable"
+ );
}
#[test]
@@ -1094,6 +1115,7 @@ slot_id = "sidebar"
// Mirrors the templated operator shape: section policy scalars in the
// head block and a per-slot prebid provider subtable.
let existing = "[creative_opportunities]\n\
+ enabled = true\n\
gam_network_id = \"111\"\n\
auction_timeout_ms = 2000\n\
section_root = \"homepage\"\n\n\
@@ -1109,6 +1131,7 @@ slot_id = "sidebar"
let existing_config = existing_config(
&existing
.replace("[creative_opportunities]\n", "")
+ .replacen("enabled = true\n", "", 1)
.replace("[[creative_opportunities.slot]]", "[[slot]]")
.replace("[creative_opportunities.slot.", "[slot.")
.replace("\n[auction]\nenabled = true\n", ""),
@@ -1150,7 +1173,7 @@ slot_id = "sidebar"
#[test]
fn splice_preserves_crlf_line_endings() {
- let existing = "[creative_opportunities]\r\ngam_network_id = \"111\"\r\n\r\n\
+ let existing = "[creative_opportunities]\r\nenabled = true\r\ngam_network_id = \"111\"\r\n\r\n\
[auction]\r\nenabled = true\r\n";
let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered())
@@ -1340,7 +1363,7 @@ slot_id = "sidebar"
fn splice_recognizes_inline_commented_section_header() {
// `[creative_opportunities] # comment` is valid TOML; the splice must
// update it in place instead of appending a duplicate section.
- let existing = "[creative_opportunities] # ad templates\ngam_network_id = \"111\"\n\n\
+ let existing = "[creative_opportunities] # ad templates\nenabled = true\ngam_network_id = \"111\"\n\n\
[auction] # flags\nenabled = true\n";
let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered())
@@ -1372,8 +1395,7 @@ slot_id = "sidebar"
#[test]
fn splice_inserts_when_no_existing_slots() {
- let existing =
- "[creative_opportunities]\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n";
+ let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n";
let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered())
.expect("should splice");
@@ -1398,6 +1420,7 @@ slot_id = "sidebar"
#[test]
fn splice_replaces_inline_slot_array() {
let existing = "[creative_opportunities]\n\
+ enabled = true\n\
gam_network_id = \"111\"\n\
slot = [{ id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] }]\n\n\
[auction]\nenabled = true\n";
@@ -1421,6 +1444,7 @@ slot_id = "sidebar"
#[test]
fn splice_replaces_inline_slot_map() {
let existing = "[creative_opportunities]\n\
+ enabled = true\n\
gam_network_id = \"111\"\n\
slot = { \"0\" = { id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] } }\n";
@@ -1721,7 +1745,7 @@ slot_id = "sidebar"
#[test]
fn replace_key_handles_inline_commented_headers() {
- let document = "[creative_opportunities] # managed\ngam_network_id = \"111\"\n\n\
+ let document = "[creative_opportunities] # managed\nenabled = true\ngam_network_id = \"111\"\n\n\
[auction] # flags\nenabled = true\n";
let updated = replace_key_in_section(
@@ -1758,7 +1782,7 @@ slot_id = "sidebar"
false,
);
let doc = format!(
- "[creative_opportunities]\ngam_network_id = \"1\"\n{}",
+ "[creative_opportunities]\nenabled = true\ngam_network_id = \"1\"\n{}",
render_slots(&merged)
);
diff --git a/crates/trusted-server-cli/src/commands/audit/mod.rs b/crates/trusted-server-cli/src/commands/audit/mod.rs
index 11138ccd8..e4ea2b628 100644
--- a/crates/trusted-server-cli/src/commands/audit/mod.rs
+++ b/crates/trusted-server-cli/src/commands/audit/mod.rs
@@ -466,7 +466,7 @@ mod tests {
#[test]
fn invalid_setting_outside_the_section_still_yields_creative_config() {
let document = "unknown_runtime_key = true\n\
- [creative_opportunities]\ngam_network_id = \"123\"\n";
+ [creative_opportunities]\nenabled = true\ngam_network_id = \"123\"\n";
let creative = creative_config(document)
.expect("an unrelated invalid setting must not hide creative config")
diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs
index 5caab273b..e0d6a8c34 100644
--- a/crates/trusted-server-cli/tests/config_env_overlay.rs
+++ b/crates/trusted-server-cli/tests/config_env_overlay.rs
@@ -50,6 +50,7 @@ fn migrated_legacy_project() -> MigratedProject {
// expected to take effect.
document["auction"]["rewrite_creatives"] = value(true);
document["auction"]["sanitize_creatives"] = value(false);
+ document["integrations"]["gpt"]["gam_attribution_enabled"] = value(false);
document["creative_opportunities"]["enabled"] = value(true);
document["creative_opportunities"]["gam_network_id"] = value("123456789");
fs::write(&config_path, document.to_string()).expect("should write migrated config");
@@ -156,7 +157,7 @@ fn migrated_legacy_config_applies_boolean_environment_overrides() {
assert_eq!(
envelope["data"]["integrations"]["gpt"]["gam_attribution_enabled"],
serde_json::Value::Bool(true),
- "pushed config should contain the GAM attribution environment override"
+ "pushed config should contain the GAM attribution environment override: {envelope}"
);
assert_eq!(
envelope["data"]["creative_opportunities"]["enabled"],
diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs
index 19aa0b82f..20993125e 100644
--- a/crates/trusted-server-core/benches/html_processor_bench.rs
+++ b/crates/trusted-server-core/benches/html_processor_bench.rs
@@ -16,11 +16,9 @@ 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,
- // The benchmark measures URL rewriting, not ad injection, and
- // `ad_slots_script` is `None` here — matching the previous behaviour,
- // which inferred no body-close work from that.
- body_close: BodyCloseInjection::None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
+ body_close: BodyCloseInjection::None,
}
}
diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs
index fdf387e93..c47586a67 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};
@@ -20,20 +20,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;
@@ -45,6 +46,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`].
@@ -168,6 +229,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.
@@ -221,15 +298,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
@@ -326,10 +409,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,
@@ -357,7 +441,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),
},
)
})
@@ -366,8 +450,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
);
@@ -572,11 +656,10 @@ mod tests {
use crate::consent::types::ConsentContext;
use crate::openrtb::Uid;
use crate::platform::test_support::{
- NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient,
- noop_services,
+ NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, noop_services,
};
- use crate::platform::{ClientInfo, PlatformHttpClient, PlatformHttpRequest, PlatformResponse};
- use crate::test_support::tests::{crate_test_settings_str, create_test_settings};
+ use crate::platform::{ClientInfo, PlatformResponse};
+ use crate::test_support::tests::create_test_settings;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde_json::json;
@@ -661,41 +744,25 @@ mod tests {
}
}
- /// Provider used to prove that direct `/auction` remains available when
- /// publisher server-side ad templates are disabled.
- struct TemplateSwitchProbeProvider {
- calls: Arc>,
+ struct CallRecordingProvider {
+ called: Arc>,
}
#[async_trait::async_trait(?Send)]
- impl AuctionProvider for TemplateSwitchProbeProvider {
+ impl AuctionProvider for CallRecordingProvider {
fn provider_name(&self) -> &'static str {
- "template_switch_probe"
+ "call_recording_provider"
}
async fn request_bids(
&self,
_request: &AuctionRequest,
- context: &AuctionContext<'_>,
+ _context: &AuctionContext<'_>,
) -> Result> {
- *self.calls.lock().expect("should lock provider call count") += 1;
- let request = Request::builder()
- .method("POST")
- .uri("https://bidder.example/auction")
- .body(EdgeBody::empty())
- .expect("should build probe provider request");
- context
- .services
- .http_client()
- .send_async(PlatformHttpRequest::new(
- request,
- "template-switch-probe-backend",
- ))
- .await
- .change_context(TrustedServerError::Auction {
- message: "probe provider launch failed".to_string(),
- })
- .map(ProviderRequestOutcome::pending)
+ *self.called.lock().expect("should lock provider call flag") = true;
+ Err(Report::new(TrustedServerError::Auction {
+ message: "call recorded".to_string(),
+ }))
}
async fn parse_response(
@@ -703,11 +770,7 @@ mod tests {
_response: PlatformResponse,
_response_time_ms: u64,
) -> Result> {
- Ok(AuctionResponse::success(
- self.provider_name(),
- Vec::new(),
- 0,
- ))
+ panic!("parse_response must not run when the launch returns an error");
}
fn timeout_ms(&self) -> u32 {
@@ -715,35 +778,30 @@ mod tests {
}
fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option {
- Some("template-switch-probe-backend".to_string())
+ Some("call-recording-backend".to_string())
}
}
#[tokio::test]
- async fn direct_auction_remains_available_when_templates_are_disabled() {
- let settings_toml = format!(
- "{}\n[auction]\nenabled = true\nproviders = [\"template_switch_probe\"]\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n",
- crate_test_settings_str()
+ async fn disabled_creative_opportunities_do_not_disable_direct_auction() {
+ let mut settings = create_test_settings();
+ settings.creative_opportunities = Some(
+ toml::from_str("enabled = false\ngam_network_id = \"12345\"\n")
+ .expect("should build disabled creative opportunity config"),
);
- let settings = Settings::from_toml(&settings_toml)
- .expect("should parse settings with disabled templates");
- let calls = Arc::new(Mutex::new(0));
- let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone());
- orchestrator.register_provider(Arc::new(TemplateSwitchProbeProvider {
- calls: Arc::clone(&calls),
+ let config = AuctionConfig {
+ enabled: true,
+ providers: vec!["call_recording_provider".to_string()],
+ timeout_ms: 2_000,
+ mediator: None,
+ ..Default::default()
+ };
+ let called = Arc::new(Mutex::new(false));
+ let mut orchestrator = AuctionOrchestrator::new(config);
+ orchestrator.register_provider(Arc::new(CallRecordingProvider {
+ called: Arc::clone(&called),
}));
-
- let stub = Arc::new(StubHttpClient::new());
- stub.push_response(200, b"probe response".to_vec());
- let services = RuntimeServices::builder()
- .config_store(Arc::new(NoopConfigStore))
- .secret_store(Arc::new(NoopSecretStore))
- .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore))
- .backend(Arc::new(NoopBackend))
- .http_client(Arc::clone(&stub) as Arc)
- .geo(Arc::new(NoopGeo))
- .client_info(ClientInfo::default())
- .build();
+ let services = noop_services();
let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
let body = json!({
"adUnits": [{
@@ -759,7 +817,7 @@ mod tests {
))
.expect("should build auction request");
- let response = handle_auction(
+ let _ = handle_auction(
&settings,
&orchestrator,
None,
@@ -768,15 +826,12 @@ mod tests {
&services,
req,
)
- .await
- .expect("direct auction should remain available");
+ .await;
- assert_eq!(
- *calls.lock().expect("should lock provider call count"),
- 1,
- "disabling publisher templates must not disable direct /auction"
+ assert!(
+ *called.lock().expect("should lock provider call flag"),
+ "direct /auction must remain live when publisher template delivery is disabled"
);
- assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
@@ -844,6 +899,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..cccd9c24f 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)
+ }
+
+ pub(crate) 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 728cc1efe..3494b7361 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]
@@ -182,6 +181,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,
@@ -205,6 +221,7 @@ fn snapshot_context_request(request: &Request) -> Request {
pub struct AuctionOrchestrator {
config: AuctionConfig,
providers: HashMap>,
+ identity_generator: Arc,
}
impl AuctionOrchestrator {
@@ -214,6 +231,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,
}
}
@@ -277,6 +307,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:
@@ -294,6 +684,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() {
(
@@ -330,119 +734,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(),
})
@@ -454,14 +864,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(),
})
@@ -479,9 +893,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
@@ -490,14 +902,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!(
@@ -513,8 +925,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,
@@ -545,20 +955,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;
@@ -585,14 +998,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!(
@@ -605,6 +1017,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",
@@ -634,12 +1061,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;
@@ -657,18 +1086,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));
@@ -685,7 +1103,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;
@@ -742,7 +1160,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]
@@ -850,9 +1271,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,
};
@@ -919,23 +1351,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
@@ -943,28 +1358,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 \
@@ -972,16 +1390,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 => {
@@ -1014,20 +1430,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));
@@ -1048,16 +1468,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",
@@ -1069,9 +1483,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,
);
@@ -1101,7 +1520,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;
@@ -1118,18 +1540,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(),
@@ -1137,7 +1547,7 @@ impl AuctionOrchestrator {
context.timeout_ms
);
- DispatchAuctionOutcome::Dispatched(DispatchedAuction {
+ DispatchedAuction {
pending_requests,
backend_to_provider,
completed_responses,
@@ -1146,7 +1556,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.
@@ -1239,7 +1649,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!(
@@ -1315,50 +1728,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())
@@ -1370,93 +1758,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(),
}
@@ -1478,6 +1857,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
@@ -1514,12 +1895,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::{
@@ -1533,9 +1916,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
@@ -1544,6 +1928,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)]
@@ -1557,6 +1981,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")
@@ -1607,82 +2032,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,
@@ -1700,7 +2061,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")
@@ -1711,7 +2072,7 @@ mod tests {
context
.services
.http_client()
- .send_async(request)
+ .send_async(req)
.await
.change_context(TrustedServerError::Auction {
message: "divergent launch failed".to_string(),
@@ -1772,14 +2133,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
@@ -1789,7 +2150,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(),
@@ -1803,6 +2164,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
@@ -1823,28 +2187,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())
}
}
@@ -1896,12 +2363,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,
))
}
@@ -1926,13 +2399,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,
)))
}
@@ -1971,9 +2449,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));
@@ -2027,9 +2504,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();
@@ -2044,11 +2520,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
@@ -2118,6 +2590,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")
@@ -2229,6 +3069,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]
@@ -2246,12 +3097,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;
@@ -2259,6 +3107,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]
@@ -2272,10 +3131,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);
@@ -2285,11 +3144,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
@@ -2419,6 +3274,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()),
@@ -2442,6 +3300,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()),
@@ -2512,14 +3373,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,
@@ -2539,16 +3411,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,
+ },
+ ]
);
});
}
@@ -2592,14 +3468,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);
@@ -2609,11 +3485,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
@@ -2734,13 +3606,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()));
@@ -2760,14 +3636,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,
+ },
+ ]
+ );
});
}
@@ -2794,10 +3736,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",
@@ -2863,11 +3805,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;
@@ -2915,18 +3853,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"
+ );
});
}
@@ -2958,21 +3899,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);
});
}
@@ -3000,14 +3943,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();
@@ -3075,10 +4018,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()
@@ -3093,13 +4035,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())
@@ -3153,14 +4091,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();
@@ -3180,11 +4118,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(),
@@ -3218,14 +4166,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();
@@ -3245,15 +4193,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,
+ ..
+ }
+ )));
});
}
@@ -3303,6 +4263,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()),
@@ -3347,6 +4310,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()),
@@ -3386,6 +4352,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..0c3335af3 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,11 +951,11 @@ 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.
- /// Used as `hb_adid` targeting value in `window.tsjs.bids`. `None` for
+ /// Used as the `hb_adid` value in the initial browser auction projection. `None` for
/// non-PBS providers (e.g., APS) and PBS bids without Prebid Cache enabled.
pub cache_id: Option,
/// Prebid Cache host (e.g., `"openads.adsrvr.org"`).
@@ -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 6c92d042d..26a1da1b8 100644
--- a/crates/trusted-server-core/src/auth.rs
+++ b/crates/trusted-server-core/src/auth.rs
@@ -300,9 +300,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/config.rs b/crates/trusted-server-core/src/config.rs
index 66a0232df..678f3273c 100644
--- a/crates/trusted-server-core/src/config.rs
+++ b/crates/trusted-server-core/src/config.rs
@@ -226,6 +226,7 @@ mod tests {
#[serde(deny_unknown_fields)]
#[allow(dead_code)]
struct LegacyCreativeOpportunitiesConfig {
+ enabled: bool,
gam_network_id: String,
#[serde(default)]
auction_timeout_ms: Option,
@@ -241,6 +242,7 @@ mod tests {
r#"
[creative_opportunities]
+enabled = true
gam_network_id = "99999"
[[creative_opportunities.slot]]
@@ -325,37 +327,10 @@ formats = [{ width = 300, height = 250 }]
fn absent_gam_unit_template_is_accepted_by_legacy_schema() {
let creative_opportunities = serialized_creative_opportunities(None);
- assert!(
- creative_opportunities.get("enabled").is_none(),
- "default template switch should be omitted for legacy binaries"
- );
serde_json::from_value::(creative_opportunities)
.expect("should accept absent GAM unit template");
}
- #[test]
- fn disabled_creative_opportunities_flag_is_rejected_by_legacy_schema() {
- let mut toml = crate_test_settings_str();
- toml.push_str(
- r#"
-
-[creative_opportunities]
-enabled = false
-gam_network_id = "99999"
-"#,
- );
- let app_config: TrustedServerAppConfig =
- toml::from_str(&toml).expect("should deserialize app config wrapper");
- let creative_opportunities = serde_json::to_value(app_config)
- .expect("should serialize app config wrapper")
- .get("creative_opportunities")
- .cloned()
- .expect("should contain creative opportunities");
-
- serde_json::from_value::(creative_opportunities)
- .expect_err("legacy binaries should reject an explicit disabled switch");
- }
-
#[test]
fn deploy_validation_rejects_placeholders() {
let settings = Settings::from_toml(
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..095247142 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>`
/// - ` elements (common in CMS template pages)
let output = processor
- .process_chunk(b"content", true)
+ .process_chunk(
+ b"content",
+ true,
+ )
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
assert_eq!(
- html.matches(".bids=JSON.parse").count(),
+ html.matches("const __TSJS_SERVER_BOOT_TRANSPORT_V1__=")
+ .count(),
1,
- "should inject tsjs.bids exactly once even with multiple elements"
+ "should inject immutable boot exactly once even with multiple body elements"
);
+ assert!(!html.contains(".bids="));
}
#[test]
@@ -1962,6 +2077,7 @@ mod tests {
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,
};
let mut processor = create_html_processor(config);
@@ -1993,7 +2109,7 @@ mod tests {
.expect("should process HTML");
let output_str = std::str::from_utf8(&output).expect("should be valid UTF-8");
- let script_count = output_str.matches("/static/tsjs=").count();
+ let script_count = output_str.matches("id=\"trustedserver-js\"").count();
assert_eq!(
script_count, 1,
"script tag must appear exactly once, found {script_count} occurrences"
@@ -2001,13 +2117,11 @@ mod tests {
}
#[test]
- fn injects_empty_ts_bids_when_slots_matched_but_auction_returned_nothing() {
- // Slots matched (ad_slots_script is Some) but auction task never wrote a result
- // (state is None) — e.g. auction timed out with zero bids. Fallback to {}.
+ fn injects_safe_empty_projection_when_auction_returned_nothing() {
let state = std::sync::Arc::new(std::sync::Mutex::new(None));
let config = HtmlProcessorConfig {
csp_nonce_observed: None,
- body_close: BodyCloseInjection::InlineBids,
+ body_close: BodyCloseInjection::None,
origin_host: "origin.example.com".to_string(),
request_host: "example.com".to_string(),
request_scheme: "https".to_string(),
@@ -2018,6 +2132,7 @@ mod tests {
ad_bids_state: state,
max_buffered_body_bytes: 16 * 1024 * 1024,
gpt_diagnostics: None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
};
let mut processor = create_html_processor(config);
@@ -2025,17 +2140,21 @@ mod tests {
.process_chunk(b"content", true)
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
- assert!(
- html.contains("JSON.parse(\"{}\")"),
- "should inject empty bids fallback when auction produced nothing"
- );
+ assert_eq!(
+ bootstrap_transport(html)["boot"]["auctionProjection"],
+ json!({
+ "version": 1,
+ "auction": {"version": 1, "auctionId": "initial", "results": []},
+ "slots": [],
+ "bids": []
+ }),
+ "should inject the exact safe empty initial projection"
+ );
+ assert!(!html.contains(".bids="));
}
#[test]
- fn does_not_inject_ts_bids_when_no_slots_matched() {
- // No slots matched this URL — ad_slots_script is None. tsjs.bids must be
- // omitted entirely so the publisher's existing client-side GPT flow is
- // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified").
+ fn emits_empty_boot_but_no_legacy_bids_when_no_slots_matched() {
let state = std::sync::Arc::new(std::sync::Mutex::new(None));
let config = HtmlProcessorConfig {
csp_nonce_observed: None,
@@ -2048,6 +2167,7 @@ mod tests {
ad_bids_state: state,
max_buffered_body_bytes: 16 * 1024 * 1024,
gpt_diagnostics: None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
};
let mut processor = create_html_processor(config);
@@ -2055,10 +2175,13 @@ mod tests {
.process_chunk(b"content", true)
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
- assert!(
- !html.contains("JSON.parse"),
- "should NOT inject tsjs.bids when no slots matched"
+ assert_eq!(
+ html.matches("const __TSJS_SERVER_BOOT_TRANSPORT_V1__=")
+ .count(),
+ 1,
+ "every document should receive one complete server boot input"
);
+ assert!(!html.contains(".bids="));
}
fn marker_mode_config(marker: &str, observer: Option>) -> HtmlProcessorConfig {
@@ -2073,6 +2196,7 @@ mod tests {
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,
}
}
@@ -2209,6 +2333,7 @@ mod tests {
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,
};
let source =
diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs
index ba3f82776..418b2dd10 100644
--- a/crates/trusted-server-core/src/integrations/adserver_mock.rs
+++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs
@@ -97,36 +97,43 @@ impl IntegrationConfig for AdServerMockConfig {
/// mediation response to restore render/accounting fields that the mock
/// mediator endpoint does not echo back.
///
-/// Keyed by `(provider_name, slot_id, bidder_name)`.
-type BidIndex = HashMap<(String, String, String), Bid>;
+/// Keyed only by the server-minted opaque candidate identifier.
+type BidIndex = HashMap;
+
+fn optional_selection_field_matches(
+ selection: &serde_json::Map,
+ field: &str,
+ expected: &Option,
+) -> bool {
+ let Some(actual) = selection.get(field) else {
+ return true;
+ };
+ expected
+ .as_ref()
+ .and_then(|expected| serde_json::to_value(expected).ok())
+ .is_some_and(|expected| expected == *actual)
+}
/// Builds the SSP-bid lookup index from the orchestrator-provided
/// bidder responses on the auction context.
-fn build_bid_index(bidder_responses: &[AuctionResponse]) -> BidIndex {
+fn build_bid_index(bidder_responses: &[AuctionResponse]) -> Option {
let mut index = BidIndex::new();
for response in bidder_responses {
for bid in &response.bids {
- let key = (
- response.provider.clone(),
- bid.slot_id.clone(),
- bid.bidder.clone(),
- );
- // OpenRTB permits a seat to return multiple bids per imp. This index
- // is last-write-wins, so a collision means an earlier bid's
- // nurl/burl/cache_* are dropped and win/billing-URL restoration can
- // be mis-attributed during mediation. Low severity for the mock
- // mediator, but log it so the collision is visible.
- if index.insert(key, bid.clone()).is_some() {
+ let Some(candidate_id) = bid.candidate_id.as_ref() else {
log::warn!(
- "adserver_mock: duplicate bid for (provider '{}', slot '{}', bidder '{}'); keeping the last — win/billing URL restoration may be mis-attributed",
- response.provider,
- bid.slot_id,
- bid.bidder
+ "adserver_mock: source bid from provider '{}' lacks a candidate id",
+ response.provider
);
+ return None;
+ };
+ if index.insert(candidate_id.clone(), bid.clone()).is_some() {
+ log::warn!("adserver_mock: duplicate source candidate id");
+ return None;
}
}
}
- index
+ Some(index)
}
/// Mock ad server mediator provider.
@@ -179,14 +186,26 @@ impl AdServerMockProvider {
);
return None;
};
+ let Some(candidate_id) = bid.candidate_id.as_deref() else {
+ log::warn!(
+ "adserver_mock: omitting source bid for slot '{}' without a candidate id",
+ bid.slot_id
+ );
+ return None;
+ };
Some(json!({
"imp_id": bid.slot_id,
"price": price,
"adm": bid.creative,
"w": bid.width,
"h": bid.height,
- "crid": format!("{}-creative", bid.bidder),
+ "crid": bid.creative_id,
"adomain": bid.adomain,
+ "ext": {
+ "trusted_server": {
+ "candidate_id": candidate_id
+ }
+ }
}))
})
.collect();
@@ -254,90 +273,115 @@ impl AdServerMockProvider {
/// Mediation returns decoded prices for all selected bids.
///
/// `bid_index` is the SSP-bid lookup built from the auction context's
- /// bidder responses. The mock mediator does not echo render/accounting
- /// fields back, so they are restored from the index using
- /// `(seat, impid, bidder)` where bidder is recovered from the echoed `crid`
- /// field (`"{bidder}-creative"` format set during request construction).
+ /// bidder responses. The mediator may select only by echoing one exact
+ /// server-minted candidate id; all render and accounting authority is
+ /// restored from the indexed source candidate.
fn parse_mediation_response(
&self,
json: &Json,
response_time_ms: u64,
bid_index: &BidIndex,
) -> AuctionResponse {
- let empty_array = vec![];
- let seatbid = json["seatbid"].as_array().unwrap_or(&empty_array);
-
+ let Some(seatbids) = json.get("seatbid").and_then(Json::as_array) else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
let mut all_bids = Vec::new();
-
- for seat in seatbid {
- let seat_name = seat["seat"].as_str().unwrap_or("unknown");
- let empty_bids = vec![];
- let bids = seat["bid"].as_array().unwrap_or(&empty_bids);
-
+ let mut seen = std::collections::HashSet::new();
+ let mut seen_slots = std::collections::HashSet::new();
+ for seat in seatbids {
+ let Some(seat_object) = seat.as_object() else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
+ if !seat_object
+ .keys()
+ .all(|key| matches!(key.as_str(), "seat" | "bid"))
+ {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ }
+ let Some(bids) = seat_object.get("bid").and_then(Json::as_array) else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
for bid in bids {
- let slot_id = bid["impid"].as_str().unwrap_or("").to_string();
-
- // Recover bidder name from crid ("{bidder}-creative") to look up the
- // original SSP bid and restore render/accounting fields the mediator drops.
- let crid = bid["crid"].as_str().unwrap_or("");
- let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| {
- log::debug!(
- "adserver_mock: crid '{crid}' does not match '-creative'; render/accounting fields may be missing"
- );
- ""
- });
- let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string());
- let original = bid_index.get(&key);
- let restored_bidder =
- original.map_or_else(|| seat_name.to_string(), |b| b.bidder.clone());
-
- let width = bid["w"].as_u64().unwrap_or(0) as u32;
- let height = bid["h"].as_u64().unwrap_or(0) as u32;
- if width == 0 || height == 0 {
- log::debug!(
- "adserver_mock: bid for slot '{slot_id}' has zero dimension ({width}×{height}), skipping"
- );
- continue;
+ let Some(selection) = bid.as_object() else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
+ if !selection.keys().all(|key| {
+ matches!(
+ key.as_str(),
+ "id" | "impid"
+ | "price"
+ | "ext"
+ | "adm"
+ | "w"
+ | "h"
+ | "crid"
+ | "adomain"
+ | "nurl"
+ | "burl"
+ )
+ }) {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ }
+ let trusted = selection
+ .get("ext")
+ .and_then(Json::as_object)
+ .filter(|ext| ext.len() == 1)
+ .and_then(|ext| ext.get("trusted_server"))
+ .and_then(Json::as_object);
+ let candidate_id = trusted
+ .filter(|trusted| trusted.len() == 1)
+ .and_then(|trusted| trusted.get("candidate_id"))
+ .and_then(Json::as_str);
+ let Some(candidate_id) = candidate_id else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
+ let Some(original) = bid_index.get(candidate_id) else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
+ let seat_matches = seat_object
+ .get("seat")
+ .is_none_or(|seat| seat.as_str() == original.candidate_provider.as_deref());
+ let currency_matches = json
+ .get("cur")
+ .is_none_or(|currency| currency.as_str() == Some(original.currency.as_str()));
+ let authority_matches = selection
+ .get("w")
+ .is_none_or(|width| width.as_u64() == Some(u64::from(original.width)))
+ && selection
+ .get("h")
+ .is_none_or(|height| height.as_u64() == Some(u64::from(original.height)))
+ && optional_selection_field_matches(selection, "adm", &original.creative)
+ && optional_selection_field_matches(selection, "crid", &original.creative_id)
+ && optional_selection_field_matches(selection, "adomain", &original.adomain)
+ && optional_selection_field_matches(selection, "nurl", &original.nurl)
+ && optional_selection_field_matches(selection, "burl", &original.burl);
+ if !seen.insert(candidate_id)
+ || !seen_slots.insert(original.slot_id.as_str())
+ || selection.get("impid").and_then(Json::as_str)
+ != Some(original.slot_id.as_str())
+ || selection
+ .get("price")
+ .and_then(Json::as_f64)
+ .is_none_or(|price| !price.is_finite() || price < 0.0)
+ || !seat_matches
+ || !currency_matches
+ || !authority_matches
+ {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
}
- all_bids.push(Bid {
- slot_id,
- price: bid["price"].as_f64(),
- currency: "USD".to_string(),
- creative: if original.is_some_and(|bid| bid.renderer.is_some()) {
- None
- } else {
- bid["adm"].as_str().map(String::from)
- },
- width,
- height,
- bidder: restored_bidder,
- adomain: bid["adomain"].as_array().map(|arr| {
- arr.iter()
- .filter_map(|v| v.as_str().map(String::from))
- .collect()
- }),
- nurl: original.and_then(|b| b.nurl.clone()),
- burl: original.and_then(|b| b.burl.clone()),
- // The mediation response is itself `OpenRTB`, so the mediated
- // bid's own `id` is this bid's identifier. Fall back to the
- // original SSP bid's id when the mediator omits one. Without
- // either, a mediated bid whose only `hb_adid` source is the bid
- // id would lose it and never render — including APS bids, which
- // carry no `ad_id` or `cache_id` for the restore to recover.
- bid_id: bid["id"]
- .as_str()
- .filter(|id| !id.is_empty())
- .map(String::from)
- .or_else(|| original.and_then(|bid| bid.bid_id.clone())),
- ad_id: original.and_then(|bid| bid.ad_id.clone()),
- creative_id: original.and_then(|bid| bid.creative_id.clone()),
- renderer: original.and_then(|bid| bid.renderer.clone()),
- cache_id: original.and_then(|b| b.cache_id.clone()),
- cache_host: original.and_then(|b| b.cache_host.clone()),
- cache_path: original.and_then(|b| b.cache_path.clone()),
- metadata: HashMap::new(),
- });
+ let mut resolved = original.clone();
+ resolved.price = selection.get("price").and_then(Json::as_f64);
+ all_bids.push(resolved);
}
}
@@ -517,7 +561,10 @@ impl AuctionProvider for AdServerMockProvider {
// Rebuild the SSP-bid lookup from the orchestrator-provided bidder
// responses so nurl/burl/ad_id survive mediation. Request-scoped data
// travels on the context instead of provider-instance state.
- let bid_index = build_bid_index(context.provider_responses.unwrap_or(&[]));
+ let Some(bid_index) = build_bid_index(context.provider_responses.unwrap_or(&[])) else {
+ return Ok(AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance")));
+ };
self.parse_response_inner(response, response_time_ms, &bid_index)
.await
}
@@ -631,6 +678,12 @@ mod tests {
fn aps_bid(bid_id: &str, price: f64) -> Bid {
Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some(format!(
+ "{:012x}",
+ bid_id.bytes().map(u64::from).sum::()
+ )),
+ candidate_provider: Some("aps".to_string()),
+ renderer_reservation_id: None,
price: Some(price),
currency: "USD".to_string(),
creative: None,
@@ -643,7 +696,7 @@ mod tests {
bid_id: Some(bid_id.to_string()),
ad_id: None,
creative_id: Some(format!("creative-{bid_id}")),
- renderer: Some(BidRenderer::Aps(ApsRendererV1 {
+ renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: bid_id.to_string(),
@@ -680,6 +733,9 @@ mod tests {
status: BidStatus::Success,
bids: vec![Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some("AAAAAAAAAAAA".to_string()),
+ candidate_provider: Some("aps".to_string()),
+ renderer_reservation_id: None,
price: Some(3.00),
currency: "USD".to_string(),
creative: Some("APS Ad
".to_string()),
@@ -706,6 +762,9 @@ mod tests {
status: BidStatus::Success,
bids: vec![Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some("BBBBBBBBBBBB".to_string()),
+ candidate_provider: Some("test-bidder".to_string()),
+ renderer_reservation_id: None,
price: Some(3.50),
currency: "USD".to_string(),
creative: Some("Test Ad
".to_string()),
@@ -750,28 +809,38 @@ mod tests {
2
);
assert_eq!(mediation_req["ext"]["config"]["price_floor"], 1.00);
+ assert_eq!(
+ mediation_req["ext"]["bidder_responses"][0]["bids"][0]["ext"]["trusted_server"]["candidate_id"],
+ "AAAAAAAAAAAA"
+ );
+ assert_eq!(
+ mediation_req["ext"]["bidder_responses"][1]["bids"][0]["ext"]["trusted_server"]["candidate_id"],
+ "BBBBBBBBBBBB"
+ );
}
#[test]
fn test_parse_mediation_response() {
let config = AdServerMockConfig::default();
let provider = AdServerMockProvider::new(config);
+ let source = aps_bid("selected", 1.0);
+ let candidate_id = source
+ .candidate_id
+ .clone()
+ .expect("source should have candidate id");
+ let bid_index = HashMap::from([(candidate_id.clone(), source)]);
let mediation_response = json!({
"id": "test-auction-123",
"seatbid": [
{
- "seat": "test-bidder",
+ "seat": "aps",
"bid": [
{
"id": "bid-001",
"impid": "header-banner",
"price": 3.50,
- "adm": "Test Ad
",
- "w": 728,
- "h": 90,
- "crid": "test-creative",
- "adomain": ["test.com"]
+ "ext": {"trusted_server": {"candidate_id": candidate_id}}
}
]
}
@@ -780,7 +849,7 @@ mod tests {
});
let auction_response =
- provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new());
+ provider.parse_mediation_response(&mediation_response, 200, &bid_index);
assert_eq!(auction_response.provider, "adserver_mock");
assert_eq!(auction_response.status, BidStatus::Success);
@@ -790,7 +859,7 @@ mod tests {
let bid = &auction_response.bids[0];
assert_eq!(bid.slot_id, "header-banner");
assert_eq!(bid.price, Some(3.50)); // Mediation returns decoded price
- assert_eq!(bid.bidder, "test-bidder");
+ assert_eq!(bid.bidder, "aps");
assert_eq!(bid.width, 728);
assert_eq!(bid.height, 90);
}
@@ -798,6 +867,7 @@ mod tests {
#[test]
fn parse_mediation_response_restores_original_bid_render_fields() {
let provider = AdServerMockProvider::new(AdServerMockConfig::default());
+ let candidate_id = "AAAAAAAAAAAA";
let mediation_response = json!({
"id": "test-auction-123",
"seatbid": [
@@ -808,11 +878,7 @@ mod tests {
"id": "mediated-bid-001",
"impid": "header-banner",
"price": 0.20,
- "adm": "Mediated Ad
",
- "w": 728,
- "h": 90,
- "crid": "mocktioneer-creative",
- "adomain": ["example.com"]
+ "ext": {"trusted_server": {"candidate_id": candidate_id}}
}
]
}
@@ -821,13 +887,12 @@ mod tests {
});
let mut bid_index = BidIndex::new();
bid_index.insert(
- (
- "prebid".to_string(),
- "header-banner".to_string(),
- "mocktioneer".to_string(),
- ),
+ candidate_id.to_string(),
Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some(candidate_id.to_string()),
+ candidate_provider: Some("prebid".to_string()),
+ renderer_reservation_id: None,
price: Some(0.20),
currency: "USD".to_string(),
creative: Some("Original Ad
".to_string()),
@@ -840,7 +905,7 @@ mod tests {
bid_id: Some("source-bid-id".to_string()),
ad_id: Some("bid-impression-id".to_string()),
creative_id: Some("source-creative-id".to_string()),
- renderer: Some(BidRenderer::Aps(ApsRendererV1 {
+ renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "source-bid-id".to_string(),
@@ -878,11 +943,7 @@ mod tests {
Some("https://ssp.example/bill"),
"should restore burl"
);
- assert_eq!(
- bid.bid_id.as_deref(),
- Some("mediated-bid-001"),
- "should carry the mediated OpenRTB bid id so hb_adid always has a source"
- );
+ assert_eq!(bid.bid_id.as_deref(), Some("source-bid-id"));
assert_eq!(
bid.ad_id.as_deref(),
Some("bid-impression-id"),
@@ -908,104 +969,103 @@ mod tests {
}
#[test]
- fn parse_mediation_response_falls_back_to_original_bid_id() {
- // A mediator that omits the per-bid `id` must not strand a pass-through
- // bid whose only hb_adid source is its OpenRTB bid id.
+ fn mediation_response_rejects_every_source_authority_substitution() {
let provider = AdServerMockProvider::new(AdServerMockConfig::default());
- let mediation_response = json!({
- "id": "test-auction-123",
- "seatbid": [{
- "seat": "prebid",
- "bid": [{
- "impid": "header-banner",
- "price": 0.20,
- "adm": "Mediated Ad
",
- "w": 728,
- "h": 90,
- "crid": "example-bidder-creative"
- }]
- }],
- "cur": "USD"
- });
- let mut bid_index = BidIndex::new();
- bid_index.insert(
- (
- "prebid".to_string(),
- "header-banner".to_string(),
- "example-bidder".to_string(),
- ),
- Bid {
- slot_id: "header-banner".to_string(),
- price: Some(0.20),
- currency: "USD".to_string(),
- creative: Some("Original Ad
".to_string()),
- adomain: None,
- bidder: "example-bidder".to_string(),
- width: 728,
- height: 90,
- nurl: None,
- burl: None,
- bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()),
- ad_id: None,
- creative_id: None,
- renderer: None,
- cache_id: None,
- cache_host: None,
- cache_path: None,
- metadata: HashMap::new(),
- },
- );
-
- let auction_response =
- provider.parse_mediation_response(&mediation_response, 42, &bid_index);
+ let candidate_id = "AAAAAAAAAAAA";
+ let mut source = aps_bid("source-bid", 1.0);
+ source.candidate_id = Some(candidate_id.to_string());
+ source.renderer = None;
+ source.creative = Some("source creative
".to_string());
+ source.nurl = Some("https://ssp.example/win".to_string());
+ source.burl = Some("https://ssp.example/bill".to_string());
+ let bid_index = BidIndex::from([(candidate_id.to_string(), source)]);
+
+ for substitution in [
+ "seat", "w", "h", "crid", "adomain", "nurl", "burl", "adm", "native", "renderer",
+ ] {
+ let mut response = json!({
+ "id": "test-auction-123",
+ "seatbid": [{
+ "seat": "aps",
+ "bid": [{
+ "id": "selection-1",
+ "impid": "header-banner",
+ "price": 2.0,
+ "ext": {"trusted_server": {"candidate_id": candidate_id}}
+ }]
+ }],
+ "cur": "USD"
+ });
+ match substitution {
+ "seat" => response["seatbid"][0]["seat"] = json!("substituted-provider"),
+ "w" => response["seatbid"][0]["bid"][0]["w"] = json!(1),
+ "h" => response["seatbid"][0]["bid"][0]["h"] = json!(1),
+ "crid" => response["seatbid"][0]["bid"][0]["crid"] = json!("other"),
+ "adomain" => {
+ response["seatbid"][0]["bid"][0]["adomain"] = json!(["evil.example"]);
+ }
+ "nurl" => {
+ response["seatbid"][0]["bid"][0]["nurl"] = json!("https://evil.example/win");
+ }
+ "burl" => {
+ response["seatbid"][0]["bid"][0]["burl"] = json!("https://evil.example/bill");
+ }
+ "adm" => {
+ response["seatbid"][0]["bid"][0]["adm"] =
+ json!("");
+ }
+ "native" => {
+ response["seatbid"][0]["bid"][0]["native"] = json!({"ver": "1.2"});
+ }
+ "renderer" => {
+ response["seatbid"][0]["bid"][0]["renderer"] =
+ json!({"url": "https://evil.example/render.js"});
+ }
+ _ => unreachable!("closed substitution corpus"),
+ }
- assert_eq!(
- auction_response.bids[0].bid_id.as_deref(),
- Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"),
- "should restore the original SSP bid id when the mediator omits one"
- );
+ let parsed = provider.parse_mediation_response(&response, 1, &bid_index);
+ assert_eq!(
+ parsed.status,
+ BidStatus::Error,
+ "{substitution} authority must not survive mediation"
+ );
+ assert!(parsed.bids.is_empty());
+ }
}
#[test]
- fn reduced_aps_bid_avoids_mediation_index_renderer_collision() {
+ fn candidate_index_preserves_multiple_same_provider_slot_bids() {
let provider = AdServerMockProvider::new(AdServerMockConfig::default());
-
- // Document why APS must reduce before mediation: the mediator index is
- // intentionally last-write-wins for identical provider/slot/bidder keys.
- let unreduced = AuctionResponse::success(
+ let response = AuctionResponse::success(
"aps",
- vec![aps_bid("selected", 2.0), aps_bid("losing-last", 1.0)],
+ vec![aps_bid("selected", 2.0), aps_bid("losing", 1.0)],
1,
);
- let collision_index = build_bid_index(&[unreduced]);
- let key = (
- "aps".to_string(),
- "header-banner".to_string(),
- "aps".to_string(),
- );
- assert_eq!(
- collision_index
- .get(&key)
- .and_then(|bid| bid.bid_id.as_deref()),
- Some("losing-last"),
- "an unreduced response would restore the last candidate's renderer"
- );
-
- let reduced = AuctionResponse::success("aps", vec![aps_bid("selected", 2.0)], 1);
+ let index = build_bid_index(std::slice::from_ref(&response))
+ .expect("unique candidates should build an exact index");
+ assert_eq!(index.len(), 2);
+ let selected_candidate_id = response.bids[0]
+ .candidate_id
+ .clone()
+ .expect("selected bid should have candidate id");
let mediation_request = provider
.build_mediation_request(
&create_test_auction_request(),
- std::slice::from_ref(&reduced),
+ std::slice::from_ref(&response),
)
- .expect("should build mediation request from reduced APS response");
+ .expect("should build mediation request with candidate provenance");
assert_eq!(
mediation_request["ext"]["bidder_responses"][0]["bids"]
.as_array()
.map(Vec::len),
- Some(1)
+ Some(2)
+ );
+ assert_eq!(
+ mediation_request["ext"]["bidder_responses"][0]["bids"][0]["ext"]["trusted_server"]["candidate_id"],
+ selected_candidate_id
);
- let reduced_index = build_bid_index(&[reduced]);
let mediated = provider.parse_mediation_response(
&json!({
"seatbid": [{
@@ -1013,14 +1073,12 @@ mod tests {
"bid": [{
"impid": "header-banner",
"price": 2.0,
- "w": 728,
- "h": 90,
- "crid": "aps-creative"
+ "ext": {"trusted_server": {"candidate_id": selected_candidate_id}}
}]
}]
}),
2,
- &reduced_index,
+ &index,
);
let winner = mediated
.bids
@@ -1058,6 +1116,19 @@ mod tests {
assert_eq!(auction_response.bids.len(), 0);
}
+ #[test]
+ fn missing_seatbid_is_mediation_failure_not_no_bid() {
+ let provider = AdServerMockProvider::new(AdServerMockConfig::default());
+
+ let response = provider.parse_mediation_response(&json!({}), 100, &BidIndex::new());
+
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ response.metadata["mediation_error"],
+ "invalid_candidate_provenance"
+ );
+ }
+
#[test]
fn test_mediation_request_handles_decoded_bid_without_creative() {
// Typed-renderer bids retain their decoded price when sent to mediation.
@@ -1096,6 +1167,9 @@ mod tests {
status: BidStatus::Success,
bids: vec![Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: Some("CCCCCCCCCCCC".to_string()),
+ candidate_provider: Some("aps".to_string()),
+ renderer_reservation_id: None,
price: Some(1.75),
currency: "USD".to_string(),
creative: None,
@@ -1130,6 +1204,7 @@ mod tests {
let bid = &bidder_resp["bids"][0];
assert_eq!(bid["imp_id"], "slot-1");
+ assert_eq!(bid["ext"]["trusted_server"]["candidate_id"], "CCCCCCCCCCCC");
assert_eq!(
bid["price"].as_f64(),
@@ -1210,8 +1285,7 @@ mod tests {
}
#[test]
- fn test_parse_mediation_response_with_missing_prices() {
- // A malformed mediator response can still omit a selected bid price.
+ fn malformed_mediator_selection_fails_closed() {
let config = AdServerMockConfig::default();
let provider = AdServerMockProvider::new(config);
@@ -1246,20 +1320,11 @@ mod tests {
let auction_response =
provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new());
- assert_eq!(auction_response.status, BidStatus::Success);
- assert_eq!(auction_response.bids.len(), 2);
-
- // First bid should have decoded price
- let bid1 = &auction_response.bids[0];
- assert_eq!(bid1.slot_id, "header-banner");
- assert_eq!(bid1.price, Some(3.50));
-
- // Second bid should have None price (failed decode)
- let bid2 = &auction_response.bids[1];
- assert_eq!(bid2.slot_id, "sidebar");
+ assert_eq!(auction_response.status, BidStatus::Error);
+ assert!(auction_response.bids.is_empty());
assert_eq!(
- bid2.price, None,
- "Bid without price field should have None price"
+ auction_response.metadata["mediation_error"],
+ "invalid_candidate_provenance"
);
}
diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs
index 5dff15a17..fea312760 100644
--- a/crates/trusted-server-core/src/integrations/aps.rs
+++ b/crates/trusted-server-core/src/integrations/aps.rs
@@ -1,6 +1,6 @@
//! Amazon Publisher Services (APS/TAM) `OpenRTB` integration.
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
@@ -10,7 +10,7 @@ use edgezero_core::body::Body as EdgeBody;
use error_stack::{Report, ResultExt};
use http::header::HeaderName;
use http::{HeaderMap, Method, StatusCode, header};
-use serde::de::{self, Visitor};
+use serde::de::Error as _;
use serde::{Deserialize, Serialize};
use serde_json::{Value as Json, json};
use url::Url;
@@ -18,123 +18,72 @@ use validator::{Validate, ValidationError};
use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome};
use crate::auction::types::{
- AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, AuctionResponse, Bid,
- BidRenderer, MediaType,
+ AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext,
+ AuctionDropReason, AuctionDropReasons, AuctionRequest, AuctionResponse, Bid, BidRenderSourceV1,
+ MediaType, RENDER_DIMENSION_MAX, classify_aps_renderer_v1, record_auction_drop,
};
use crate::error::TrustedServerError;
+use crate::integrations::ensure_integration_backend_with_transport_timeouts;
use crate::integrations::{
- IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy,
- IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded,
+ IntegrationEndpoint, IntegrationProxy, IntegrationRegistration,
+ UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded,
ensure_integration_backend_with_timeout, predict_integration_backend_name,
};
use crate::openrtb::{
Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, ToExt, User,
UserExt, to_openrtb_i32,
};
-use crate::platform::{PlatformHttpRequest, PlatformResponse, RuntimeServices};
+use crate::platform::{
+ PlatformHttpRequest, PlatformResponse, ProxyHeaderEvidenceV1, RawProxyPolicyV1,
+ RawProxyResponseV1, RuntimeServices,
+};
use crate::settings::{IntegrationConfig, Settings};
const APS_INTEGRATION_ID: &str = "aps";
-const APS_RENDERER_ROUTE: &str = "/integrations/aps/renderer";
+pub const APS_RENDERER_V2_ROUTE: &str = "/integrations/aps/renderer/v2";
+pub const APS_RUNNER_ROUTE: &str = "/integrations/aps/runner.js";
+pub const APS_RUNNER_UPSTREAM_URL: &str =
+ "https://client.aps.amazon-adsystem.com/prebid-creative.js";
+pub const APS_RUNNER_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
+pub const APS_RENDERER_SANDBOX: &str = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation";
const DEFAULT_CURRENCY: &str = "USD";
const APS_SDK_SOURCE: &str = "prebid";
const APS_SDK_VERSION: &str = "2.2.0";
const MAX_ACCOUNT_ID_BYTES: usize = 1024;
+const MAX_BID_ID_BYTES: usize = 64;
const MAX_CREATIVE_ID_BYTES: usize = 1024;
const MAX_DEBUG_RESPONSE_PREVIEW_BYTES: usize = 512;
const MAX_CREATIVE_URL_BYTES: usize = 4096;
const MAX_LANGUAGE_BYTES: usize = 8;
const MAX_PAGE_URL_BYTES: usize = 8192;
const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024;
-const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;";
-
-const APS_RENDERER_DOCUMENT: &str = r#"
-
-
-
-"#;
-
-/// Rendering owner for selected APS bids.
-#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
-#[serde(rename_all = "snake_case")]
-pub enum ApsRenderingMode {
- /// Render through Trusted Server's opaque static renderer route.
- #[default]
- TrustedServer,
- /// Render through the injected APS runner in a publisher-origin friendly frame.
- PublisherNative,
+// Exact transport window from dispatch through the final upstream byte.
+const APS_RUNNER_TOTAL_TIMEOUT: Duration = Duration::from_secs(5);
+/// Maximum wait for the APS runner response headers.
+pub const APS_RUNNER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(4);
+/// Maximum duration of one blocking APS runner response-body read.
+pub const APS_RUNNER_BLOCKING_READ_TIMEOUT: Duration = Duration::from_millis(250);
+/// Whether `path` belongs to the reserved APS integration family.
+#[must_use]
+pub fn is_aps_family_path(path: &str) -> bool {
+ path == "/integrations/aps" || path.starts_with("/integrations/aps/")
}
+const APS_RENDERER_V2_CSP: &str = "default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' http: https:; connect-src http: https:; frame-src data: https:; img-src data: blob: http: https:; media-src blob: http: https:; style-src 'unsafe-inline' http: https:; font-src data: http: https:; worker-src blob: http: https:; frame-ancestors 'self'; form-action https:;";
+
+// This document is served with an immutable v2 URL. Any semantic change must
+// ship at a new versioned route so cached v2 bytes retain their contract.
+const APS_RENDERER_V2_DOCUMENT: &str = include_str!("generated/aps_renderer_bootstrap_v2.html");
/// Configuration for the APS `OpenRTB` integration.
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
+#[serde(deny_unknown_fields)]
#[validate(schema(function = "validate_inventory_identity_override"))]
pub struct ApsConfig {
/// Whether APS integration is enabled.
#[serde(default = "default_enabled")]
pub enabled: bool,
- /// APS account ID. `pub_id` remains a deserialization alias only.
- #[serde(alias = "pub_id", deserialize_with = "deserialize_account_id")]
+ /// APS account ID.
+ #[serde(deserialize_with = "deserialize_account_id")]
pub account_id: String,
/// APS `OpenRTB` endpoint.
#[serde(default = "default_endpoint")]
@@ -153,9 +102,6 @@ pub struct ApsConfig {
/// Whether APS script creatives are eligible before winner selection.
#[serde(default)]
pub allow_script_creatives: bool,
- /// Rendering owner for selected APS bids.
- #[serde(default)]
- pub rendering_mode: ApsRenderingMode,
/// APS-authorized inventory domain used instead of the deployment hostname.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[validate(custom(function = "validate_inventory_domain"))]
@@ -170,52 +116,15 @@ fn deserialize_account_id<'de, D>(deserializer: D) -> Result
where
D: serde::Deserializer<'de>,
{
- struct AccountIdVisitor;
-
- impl Visitor<'_> for AccountIdVisitor {
- type Value = String;
-
- fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
- formatter.write_str("a non-empty string or integer for account_id")
- }
-
- fn visit_str(self, value: &str) -> Result
- where
- E: de::Error,
- {
- let value = value.trim();
- if value.is_empty() {
- return Err(E::custom("account_id must not be empty"));
- }
- if value.len() > MAX_ACCOUNT_ID_BYTES {
- return Err(E::custom("account_id is too large"));
- }
- Ok(value.to_string())
- }
-
- fn visit_string(self, value: String) -> Result
- where
- E: de::Error,
- {
- self.visit_str(&value)
- }
-
- fn visit_i64(self, value: i64) -> Result
- where
- E: de::Error,
- {
- Ok(value.to_string())
- }
-
- fn visit_u64(self, value: u64) -> Result
- where
- E: de::Error,
- {
- Ok(value.to_string())
- }
+ let value = String::deserialize(deserializer)?;
+ let value = value.trim();
+ if value.is_empty() {
+ return Err(D::Error::custom("account_id must not be empty"));
}
-
- deserializer.deserialize_any(AccountIdVisitor)
+ if value.len() > MAX_ACCOUNT_ID_BYTES {
+ return Err(D::Error::custom("account_id is too large"));
+ }
+ Ok(value.to_string())
}
fn validate_aps_endpoint(value: &str) -> Result<(), ValidationError> {
@@ -329,7 +238,6 @@ impl Default for ApsConfig {
timeout_ms: default_timeout_ms(),
debug: false,
allow_script_creatives: false,
- rendering_mode: ApsRenderingMode::TrustedServer,
inventory_domain: None,
inventory_page_origin: None,
}
@@ -680,7 +588,7 @@ impl ApsAuctionProvider {
})
}
- fn valid_creative_url(&self, value: &str, publisher_domain: &str) -> bool {
+ fn valid_creative_url(&self, value: &str, publisher_origin: &str) -> bool {
if value.len() > MAX_CREATIVE_URL_BYTES {
return false;
}
@@ -688,14 +596,17 @@ impl ApsAuctionProvider {
return false;
};
parsed.scheme() == "https"
- && parsed
- .host_str()
- .is_some_and(|host| !host.eq_ignore_ascii_case(publisher_domain))
+ && parsed.host_str().is_some()
&& parsed.username().is_empty()
&& parsed.password().is_none()
+ && parsed.origin().ascii_serialization() != publisher_origin
}
- fn build_renderer(&self, input: ApsRendererInput<'_>) -> Option {
+ fn build_renderer(
+ &self,
+ input: ApsRendererInput<'_>,
+ publisher_origin: &str,
+ ) -> Result {
let tag_type_value = match input.tag_type {
ApsTagType::Iframe => "iframe",
ApsTagType::Script => "script",
@@ -714,11 +625,12 @@ impl ApsAuctionProvider {
}]
}]
});
- let serialized = serde_json::to_vec(&envelope).ok()?;
+ let serialized = serde_json::to_vec(&envelope)
+ .map_err(|_| AuctionDropReason::InvalidProviderResponse)?;
if serialized.len() > MAX_RENDER_ENVELOPE_BYTES {
- return None;
+ return Err(AuctionDropReason::RenderPayloadTooLarge);
}
- Some(BidRenderer::Aps(ApsRendererV1 {
+ let renderer = BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: self.config.account_id.clone(),
bid_id: input.bid_id.to_string(),
@@ -728,83 +640,110 @@ impl ApsAuctionProvider {
aax_response: BASE64_STANDARD.encode(serialized),
width: input.width,
height: input.height,
- }))
- }
-
- fn increment_reason(reasons: &mut BTreeMap, reason: &'static str) {
- *reasons.entry(reason.to_string()).or_default() += 1;
+ });
+ let value = serde_json::to_value(&renderer)
+ .map_err(|_| AuctionDropReason::InvalidProviderResponse)?;
+ if classify_aps_renderer_v1(&value, publisher_origin)
+ != ApsRendererValidationResult::Accepted
+ {
+ return Err(AuctionDropReason::InvalidProviderResponse);
+ }
+ Ok(renderer)
}
fn parse_bid(
&self,
value: &Json,
slots: &HashMap<&str, &AdSlot>,
- publisher_domain: &str,
- ) -> Result {
- let bid_id = value
- .get("id")
- .and_then(Json::as_str)
- .filter(|value| !value.is_empty())
- .ok_or("missing_render_source")?;
+ publisher_origin: &str,
+ duplicate_ids: &HashSet,
+ ) -> Result {
+ let object = value.as_object().ok_or(AuctionDropReason::MalformedBid)?;
+ let Some(bid_id_value) = object.get("id") else {
+ return Err(AuctionDropReason::MissingUpstreamBidId);
+ };
+ let bid_id = bid_id_value
+ .as_str()
+ .ok_or(AuctionDropReason::InvalidUpstreamBidId)?;
+ if bid_id.is_empty() {
+ return Err(AuctionDropReason::MissingUpstreamBidId);
+ }
+ if bid_id.len() > MAX_BID_ID_BYTES {
+ return Err(AuctionDropReason::UpstreamBidIdTooLarge);
+ }
+ if bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) {
+ return Err(AuctionDropReason::InvalidUpstreamBidId);
+ }
+ if duplicate_ids.contains(bid_id) {
+ return Err(AuctionDropReason::DuplicateUpstreamBidId);
+ }
let slot_id = value
.get("impid")
.and_then(Json::as_str)
- .ok_or("unknown_impid")?;
- let slot = slots.get(slot_id).ok_or("unknown_impid")?;
+ .ok_or(AuctionDropReason::UnknownImpression)?;
+ let slot = slots
+ .get(slot_id)
+ .ok_or(AuctionDropReason::UnknownImpression)?;
let price = value
.get("price")
.and_then(Json::as_f64)
.filter(|price| price.is_finite() && *price >= 0.0)
- .ok_or("invalid_price")?;
+ .ok_or(AuctionDropReason::InvalidPrice)?;
if value
.get("mtype")
.is_some_and(|mtype| mtype.as_i64() != Some(1))
{
- return Err("unsupported_media_type");
- }
- let width = value
- .get("w")
- .and_then(Json::as_u64)
- .and_then(|value| u32::try_from(value).ok())
- .ok_or("invalid_dimensions")?;
- let height = value
- .get("h")
- .and_then(Json::as_u64)
- .and_then(|value| u32::try_from(value).ok())
- .ok_or("invalid_dimensions")?;
+ return Err(AuctionDropReason::UnsupportedMediaType);
+ }
+ let parse_dimension = |field: &str| {
+ let number = value
+ .get(field)
+ .and_then(Json::as_f64)
+ .filter(|number| number.is_finite() && number.fract() == 0.0 && *number > 0.0)
+ .ok_or(AuctionDropReason::InvalidDimensions)?;
+ if number > RENDER_DIMENSION_MAX as f64 {
+ return Err(AuctionDropReason::DimensionsOutOfRange);
+ }
+ u32::try_from(number as u64).map_err(|_| AuctionDropReason::DimensionsOutOfRange)
+ };
+ let width = parse_dimension("w")?;
+ let height = parse_dimension("h")?;
if !Self::compatible_dimensions(slot, width, height) {
- return Err("invalid_dimensions");
+ return Err(AuctionDropReason::InvalidDimensions);
}
let ext = value
.get("ext")
.and_then(Json::as_object)
- .ok_or("missing_render_source")?;
- let creative_url = ext
- .get("creativeurl")
- .and_then(Json::as_str)
- .ok_or("missing_render_source")?;
- if !self.valid_creative_url(creative_url, publisher_domain) {
- return Err("invalid_creative_url");
+ .ok_or(AuctionDropReason::MissingCreativeUrl)?;
+ let Some(creative_url_value) = ext.get("creativeurl") else {
+ return Err(AuctionDropReason::MissingCreativeUrl);
+ };
+ let creative_url = creative_url_value
+ .as_str()
+ .ok_or(AuctionDropReason::InvalidCreativeUrl)?;
+ if !self.valid_creative_url(creative_url, publisher_origin) {
+ return Err(AuctionDropReason::InvalidCreativeUrl);
}
let tag_type = match ext.get("tagtype").and_then(Json::as_str) {
Some("iframe") => ApsTagType::Iframe,
Some("script") if self.config.allow_script_creatives => ApsTagType::Script,
- Some("script") => return Err("script_rendering_disabled"),
- _ => return Err("unsupported_tagtype"),
+ Some("script") => return Err(AuctionDropReason::ScriptRenderingDisabled),
+ _ => return Err(AuctionDropReason::InvalidTagType),
+ };
+ let creative_id = match value.get("crid") {
+ None => None,
+ Some(Json::String(creative_id)) if creative_id.is_empty() => None,
+ Some(Json::String(creative_id)) => Some(creative_id.clone()),
+ Some(_) => return Err(AuctionDropReason::InvalidCreativeId),
};
- let creative_id = value
- .get("crid")
- .and_then(Json::as_str)
- .filter(|creative_id| !creative_id.is_empty())
- .map(str::to_string);
if creative_id
.as_ref()
.is_some_and(|creative_id| creative_id.len() > MAX_CREATIVE_ID_BYTES)
{
- return Err("creative_id_too_large");
+ return Err(AuctionDropReason::CreativeIdTooLarge);
}
- let renderer = self
- .build_renderer(ApsRendererInput {
+ let renderer = self.build_renderer(
+ ApsRendererInput {
bid_id,
creative_id: creative_id.clone(),
tag_type,
@@ -812,8 +751,9 @@ impl ApsAuctionProvider {
price,
width,
height,
- })
- .ok_or("render_payload_too_large")?;
+ },
+ publisher_origin,
+ )?;
let adomain = value
.get("adomain")
.and_then(Json::as_array)
@@ -827,6 +767,9 @@ impl ApsAuctionProvider {
Ok(Bid {
slot_id: slot_id.to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(price),
currency: DEFAULT_CURRENCY.to_string(),
creative: None,
@@ -854,7 +797,6 @@ impl ApsAuctionProvider {
request: &AuctionRequest,
) -> AuctionResponse {
if !value.is_object()
- || value.get("contextual").is_some()
|| value
.get("cur")
.is_some_and(|currency| !currency.is_string())
@@ -863,15 +805,15 @@ impl ApsAuctionProvider {
.is_some_and(|seatbids| !seatbids.is_array())
{
return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1}));
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
}
if value
.get("cur")
.and_then(Json::as_str)
.is_some_and(|currency| !currency.eq_ignore_ascii_case(DEFAULT_CURRENCY))
{
- return AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"unsupported_currency": 1}));
+ return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
}
let slots: HashMap<&str, &AdSlot> = request
@@ -881,18 +823,54 @@ impl ApsAuctionProvider {
.collect();
let seatbids = value.get("seatbid").and_then(Json::as_array);
let seatbid_count = seatbids.map_or(0, Vec::len);
- let mut reasons = BTreeMap::new();
+ let mut reasons = AuctionDropReasons::new();
let mut selected: HashMap = HashMap::new();
let mut dropped = 0_u64;
+ let mut invalid_slots = HashSet::new();
+ let mut global_invalid = false;
+ if value.get("contextual").is_some() {
+ dropped += 1;
+ global_invalid = true;
+ record_auction_drop(&mut reasons, AuctionDropReason::InvalidProviderResponse);
+ }
+
+ let mut id_counts = HashMap::<&str, usize>::new();
+ for candidate in seatbids
+ .into_iter()
+ .flatten()
+ .filter_map(|seatbid| seatbid.get("bid").and_then(Json::as_array))
+ .flatten()
+ {
+ if let Some(bid_id) = candidate.get("id").and_then(Json::as_str)
+ && !bid_id.is_empty()
+ && bid_id.len() <= MAX_BID_ID_BYTES
+ && !bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f)
+ {
+ *id_counts.entry(bid_id).or_default() += 1;
+ }
+ }
+ let duplicate_ids: HashSet = id_counts
+ .into_iter()
+ .filter(|(_, count)| *count > 1)
+ .map(|(bid_id, _)| bid_id.to_string())
+ .collect();
+
+ let publisher_origin = request
+ .publisher
+ .page_url
+ .as_deref()
+ .and_then(|page_url| Url::parse(page_url).ok())
+ .map(|page_url| page_url.origin().ascii_serialization())
+ .unwrap_or_else(|| format!("https://{}", request.publisher.domain));
for seatbid in seatbids.into_iter().flatten() {
let Some(bids) = seatbid.get("bid").and_then(Json::as_array) else {
dropped += 1;
- Self::increment_reason(&mut reasons, "empty_seatbid_bids");
+ record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBidBids);
continue;
};
for value in bids {
- match self.parse_bid(value, &slots, &request.publisher.domain) {
+ match self.parse_bid(value, &slots, &publisher_origin, &duplicate_ids) {
Ok(candidate) => {
let replace = selected.get(&candidate.slot_id).is_none_or(|current| {
let candidate_price = candidate.price.unwrap_or_default();
@@ -908,42 +886,69 @@ impl ApsAuctionProvider {
.is_some()
{
dropped += 1;
- Self::increment_reason(&mut reasons, "lost_to_higher_bid");
+ record_auction_drop(
+ &mut reasons,
+ AuctionDropReason::LostToHigherBid,
+ );
}
} else {
dropped += 1;
- Self::increment_reason(&mut reasons, "lost_to_higher_bid");
+ record_auction_drop(&mut reasons, AuctionDropReason::LostToHigherBid);
}
}
Err(reason) => {
dropped += 1;
- Self::increment_reason(&mut reasons, reason);
+ if let Some(slot_id) = value
+ .get("impid")
+ .and_then(Json::as_str)
+ .filter(|slot_id| slots.contains_key(*slot_id))
+ {
+ invalid_slots.insert(slot_id.to_string());
+ } else {
+ global_invalid = true;
+ }
+ record_auction_drop(&mut reasons, reason);
}
}
}
}
if seatbid_count == 0 {
- Self::increment_reason(&mut reasons, "empty_seatbid");
+ record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBid);
}
let accepted = selected.len();
let metadata = [
("seatbid_count".to_string(), json!(seatbid_count)),
("accepted_bid_count".to_string(), json!(accepted)),
("dropped_bid_count".to_string(), json!(dropped)),
- ("drop_reasons".to_string(), json!(reasons)),
+ (
+ "invalid_slots".to_string(),
+ json!(
+ invalid_slots
+ .into_iter()
+ .map(|slot| (slot, "invalid_provider_response"))
+ .collect::>()
+ ),
+ ),
+ (
+ "global_invalid_provider_response".to_string(),
+ json!(global_invalid),
+ ),
];
- let mut response = if selected.is_empty() {
+ let mut response = if selected.is_empty() && global_invalid {
+ AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
+ } else if selected.is_empty() {
AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms)
} else {
- AuctionResponse::success(
- APS_INTEGRATION_ID,
- selected.into_values().collect(),
- response_time_ms,
- )
+ let bids = request
+ .slots
+ .iter()
+ .filter_map(|slot| selected.remove(&slot.id))
+ .collect();
+ AuctionResponse::success(APS_INTEGRATION_ID, bids, response_time_ms)
};
response.metadata.extend(metadata);
- response
+ response.with_drop_reasons(&reasons)
}
async fn parse_response_inner(
@@ -1015,7 +1020,7 @@ impl ApsAuctionProvider {
Err(error) => {
log::warn!("Failed to parse APS response JSON: {error}");
let parsed = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1}));
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
return Ok(self.attach_debug_metadata(
parsed,
debug_enabled,
@@ -1031,7 +1036,7 @@ impl ApsAuctionProvider {
"APS cannot parse a successful bid response without the original auction request context"
);
let response = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"missing_request_context": 1}));
+ .with_drop_reason(AuctionDropReason::MissingRequestContext);
return Ok(self.attach_debug_metadata(
response,
debug_enabled,
@@ -1200,66 +1205,273 @@ impl AuctionProvider for ApsAuctionProvider {
}
#[derive(Debug)]
-struct ApsRendererIntegration {
- rendering_mode: ApsRenderingMode,
+pub(crate) struct ApsV1Integration {
+ enabled: bool,
}
-#[async_trait(?Send)]
-impl IntegrationProxy for ApsRendererIntegration {
- fn integration_name(&self) -> &'static str {
- APS_INTEGRATION_ID
+impl ApsV1Integration {
+ fn mark_exact_headers(mut response: http::Response) -> http::Response {
+ response
+ .extensions_mut()
+ .insert(crate::platform::ExactResponseHeadersV1);
+ response
}
- fn routes(&self) -> Vec {
- (self.rendering_mode == ApsRenderingMode::TrustedServer)
- .then(|| IntegrationEndpoint::get(APS_RENDERER_ROUTE))
- .into_iter()
- .collect()
+ pub(crate) fn from_settings(settings: &Settings) -> Result> {
+ Ok(Self {
+ enabled: settings
+ .integration_config::(APS_INTEGRATION_ID)?
+ .is_some(),
+ })
}
- async fn handle(
- &self,
- _settings: &Settings,
- _services: &RuntimeServices,
- request: http::Request,
+ fn local_status(
+ status: StatusCode,
+ allow_get: bool,
) -> Result, Report> {
- if request.method() != Method::GET || request.uri().path() != APS_RENDERER_ROUTE {
- return http::Response::builder()
- .status(StatusCode::NOT_FOUND)
- .body(EdgeBody::from("Not Found"))
- .change_context(TrustedServerError::Integration {
- integration: APS_INTEGRATION_ID.to_string(),
- message: "Failed to build APS not-found response".to_string(),
- });
+ let mut builder = http::Response::builder()
+ .status(status)
+ .header(header::CACHE_CONTROL, "no-store");
+ if allow_get {
+ builder = builder.header(header::ALLOW, "GET");
}
+ builder
+ .body(EdgeBody::empty())
+ .change_context(TrustedServerError::Integration {
+ integration: APS_INTEGRATION_ID.to_string(),
+ message: "Failed to build local APS route response".to_string(),
+ })
+ .map(Self::mark_exact_headers)
+ }
+
+ fn renderer_response() -> Result, Report> {
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+ .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("x-content-type-options", "nosniff")
.header("referrer-policy", "no-referrer")
- .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_CSP)
- .body(EdgeBody::from(APS_RENDERER_DOCUMENT))
+ .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_V2_CSP)
+ .body(EdgeBody::from(APS_RENDERER_V2_DOCUMENT))
.change_context(TrustedServerError::Integration {
integration: APS_INTEGRATION_ID.to_string(),
- message: "Failed to build APS renderer response".to_string(),
+ message: "Failed to build APS renderer v2 response".to_string(),
})
+ .map(Self::mark_exact_headers)
+ }
+
+ fn singleton_proxy_header(
+ evidence: &ProxyHeaderEvidenceV1,
+ required: bool,
+ ) -> Result, &'static str> {
+ match evidence {
+ ProxyHeaderEvidenceV1::Occurrences(values) => match values.as_slice() {
+ [] if !required => Ok(None),
+ [value] => Ok(Some(value.as_slice())),
+ [] => Err("missing_header"),
+ _ => Err("duplicate_header"),
+ },
+ ProxyHeaderEvidenceV1::Combined(value) if !value.contains(&b',') => {
+ Ok(Some(value.as_slice()))
+ }
+ ProxyHeaderEvidenceV1::Combined(_) => Err("listed_header"),
+ ProxyHeaderEvidenceV1::Unavailable => Err("unavailable_header"),
+ }
+ }
+
+ fn trim_http_ows(value: &[u8]) -> &[u8] {
+ let start = value
+ .iter()
+ .position(|byte| !matches!(byte, b' ' | b'\t'))
+ .unwrap_or(value.len());
+ let end = value
+ .iter()
+ .rposition(|byte| !matches!(byte, b' ' | b'\t'))
+ .map_or(start, |index| index + 1);
+ &value[start..end]
+ }
+
+ fn validate_runner_content_type(evidence: &ProxyHeaderEvidenceV1) -> Result<(), &'static str> {
+ let raw = Self::singleton_proxy_header(evidence, true)?;
+ let value = std::str::from_utf8(raw.ok_or("missing_content_type")?)
+ .map_err(|_| "invalid_content_type")?;
+ if !value.is_ascii() {
+ return Err("invalid_content_type");
+ }
+ if value.as_bytes().contains(&b',') {
+ return Err("listed_content_type");
+ }
+
+ let mut parts = value.split(';');
+ let essence = parts
+ .next()
+ .ok_or("missing_content_type")?
+ .trim_matches([' ', '\t']);
+ if !essence.eq_ignore_ascii_case("application/javascript")
+ && !essence.eq_ignore_ascii_case("text/javascript")
+ {
+ return Err("rejected_content_type");
+ }
+ let Some(parameter) = parts.next() else {
+ return Ok(());
+ };
+ if parts.next().is_some() {
+ return Err("duplicate_content_type_parameter");
+ }
+ let (name, value) = parameter
+ .split_once('=')
+ .ok_or("invalid_content_type_parameter")?;
+ if !name
+ .trim_matches([' ', '\t'])
+ .eq_ignore_ascii_case("charset")
+ || !value
+ .trim_matches([' ', '\t'])
+ .eq_ignore_ascii_case("utf-8")
+ {
+ return Err("rejected_content_type_parameter");
+ }
+ Ok(())
+ }
+
+ fn validate_runner_content_encoding(
+ evidence: &ProxyHeaderEvidenceV1,
+ ) -> Result<(), &'static str> {
+ let Some(raw) = Self::singleton_proxy_header(evidence, false)? else {
+ return Ok(());
+ };
+ let value = Self::trim_http_ows(raw);
+ if value.contains(&b',') || !value.eq_ignore_ascii_case(b"identity") {
+ return Err("rejected_content_encoding");
+ }
+ Ok(())
+ }
+
+ fn validate_runner_content_length(
+ evidence: &ProxyHeaderEvidenceV1,
+ ) -> Result , &'static str> {
+ let Some(value) = Self::singleton_proxy_header(evidence, false)? else {
+ return Ok(None);
+ };
+ if value.is_empty()
+ || value.contains(&b',')
+ || !value.iter().all(u8::is_ascii_digit)
+ || (value.len() > 1 && value[0] == b'0')
+ {
+ return Err("invalid_content_length");
+ }
+ let value = std::str::from_utf8(value)
+ .map_err(|_| "invalid_content_length")?
+ .parse::()
+ .map_err(|_| "invalid_content_length")?;
+ if value > APS_RUNNER_MAX_RESPONSE_BYTES {
+ return Err("content_length_overflow");
+ }
+ Ok(Some(value))
+ }
+
+ fn validate_runner_response(response: &RawProxyResponseV1) -> Result<(), &'static str> {
+ if response.evidence.status != StatusCode::OK.as_u16() {
+ return Err("rejected_status");
+ }
+ Self::validate_runner_content_type(&response.evidence.content_type)?;
+ Self::validate_runner_content_encoding(&response.evidence.content_encoding)?;
+ let declared_length =
+ Self::validate_runner_content_length(&response.evidence.content_length)?;
+ if response.body.len() > APS_RUNNER_MAX_RESPONSE_BYTES {
+ return Err("body_overflow");
+ }
+ if declared_length.is_some_and(|length| length != response.body.len()) {
+ return Err("content_length_mismatch");
+ }
+ std::str::from_utf8(&response.body).map_err(|_| "invalid_utf8")?;
+ Ok(())
+ }
+
+ async fn runner_response(
+ services: &RuntimeServices,
+ ) -> Result, &'static str> {
+ let outbound_request = http::Request::builder()
+ .method(Method::GET)
+ .uri(APS_RUNNER_UPSTREAM_URL)
+ .header(header::ACCEPT_ENCODING, "identity")
+ .body(EdgeBody::empty())
+ .map_err(|_| "request_build_failed")?;
+ let backend = ensure_integration_backend_with_transport_timeouts(
+ services,
+ APS_RUNNER_UPSTREAM_URL,
+ APS_INTEGRATION_ID,
+ APS_RUNNER_FIRST_BYTE_TIMEOUT,
+ APS_RUNNER_BLOCKING_READ_TIMEOUT,
+ )
+ .map_err(|_| "backend_unavailable")?;
+ let response = services
+ .http_client()
+ .send_raw_proxy_v1(
+ PlatformHttpRequest::new(outbound_request, backend),
+ RawProxyPolicyV1 {
+ total_timeout: APS_RUNNER_TOTAL_TIMEOUT,
+ first_byte_timeout: APS_RUNNER_FIRST_BYTE_TIMEOUT,
+ blocking_read_timeout: APS_RUNNER_BLOCKING_READ_TIMEOUT,
+ max_response_bytes: APS_RUNNER_MAX_RESPONSE_BYTES,
+ },
+ )
+ .await
+ .map_err(|_| "transport_failed")?;
+ Self::validate_runner_response(&response)?;
+
+ http::Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ )
+ .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
+ .header("cross-origin-resource-policy", "cross-origin")
+ .header("x-content-type-options", "nosniff")
+ .header("referrer-policy", "no-referrer")
+ .body(EdgeBody::from(response.body))
+ .map(Self::mark_exact_headers)
+ .map_err(|_| "response_build_failed")
}
}
-impl IntegrationHeadInjector for ApsRendererIntegration {
- fn integration_id(&self) -> &'static str {
+#[async_trait(?Send)]
+impl IntegrationProxy for ApsV1Integration {
+ fn integration_name(&self) -> &'static str {
APS_INTEGRATION_ID
}
- fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec {
+ fn routes(&self) -> Vec {
Vec::new()
}
- fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> {
- (self.rendering_mode == ApsRenderingMode::PublisherNative)
- .then_some(("data-ts-aps-rendering-mode", "publisher_native"))
- .into_iter()
- .collect()
+ async fn handle(
+ &self,
+ _settings: &Settings,
+ services: &RuntimeServices,
+ request: http::Request,
+ ) -> Result, Report> {
+ let path = request.uri().path();
+ if !path.starts_with("/integrations/aps/") {
+ return Self::local_status(StatusCode::NOT_FOUND, false);
+ }
+ if !self.enabled {
+ return Self::local_status(StatusCode::NOT_FOUND, false);
+ }
+ if request.method() != Method::GET {
+ return Self::local_status(StatusCode::METHOD_NOT_ALLOWED, true);
+ }
+ match path {
+ APS_RENDERER_V2_ROUTE => Self::renderer_response(),
+ APS_RUNNER_ROUTE => match Self::runner_response(services).await {
+ Ok(response) => Ok(response),
+ Err(reason) => {
+ log::warn!("APS runner proxy failed closed: {reason}");
+ Self::local_status(StatusCode::BAD_GATEWAY, false)
+ }
+ },
+ _ => Self::local_status(StatusCode::NOT_FOUND, false),
+ }
}
}
@@ -1271,21 +1483,15 @@ impl IntegrationHeadInjector for ApsRendererIntegration {
pub fn register(
settings: &Settings,
) -> Result, Report> {
- let Some(config) = settings.integration_config::(APS_INTEGRATION_ID)? else {
+ let Some(_config) = settings.integration_config::(APS_INTEGRATION_ID)? else {
return Ok(None);
};
- let integration = Arc::new(ApsRendererIntegration {
- rendering_mode: config.rendering_mode,
- });
- let registration = IntegrationRegistration::builder(APS_INTEGRATION_ID)
- .without_js()
- .with_head_injector(integration.clone());
- let registration = if config.rendering_mode == ApsRenderingMode::TrustedServer {
- registration.with_proxy(integration)
- } else {
- registration
- };
- Ok(Some(registration.build()))
+ Ok(Some(
+ IntegrationRegistration::builder(APS_INTEGRATION_ID)
+ .without_js()
+ .with_empty_browser_config_v1()?
+ .build(),
+ ))
}
/// Register the APS auction provider when enabled.
@@ -1305,11 +1511,6 @@ pub fn register_providers(
"APS debug mode is ON — raw request and response data, including creative markup, will be included in client-visible /auction responses"
);
}
- if config.rendering_mode == ApsRenderingMode::PublisherNative && config.allow_script_creatives {
- log::warn!(
- "APS publisher-native rendering with script creatives is ON; selected bidder scripts execute with publisher-origin privileges"
- );
- }
Ok(vec![Arc::new(ApsAuctionProvider::new(config))])
}
@@ -1317,16 +1518,18 @@ pub fn register_providers(
mod tests {
use super::*;
use crate::auction::types::{
- AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo,
- UserInfo,
+ AdFormat, AdSlot, AuctionContext, AuctionDropReason, AuctionRequest, BidStatus, DeviceInfo,
+ PublisherInfo, UserInfo,
};
use crate::consent::ConsentContext;
- use crate::integrations::IntegrationDocumentState;
use crate::openrtb::{Eid, Uid};
- use crate::platform::GeoInfo;
use crate::platform::test_support::{
StubHttpClient, build_services_with_http_client, noop_services,
};
+ use crate::platform::{
+ GeoInfo, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1,
+ RawProxyResponseV1,
+ };
use crate::test_support::tests::create_test_settings;
use serde_json::json;
@@ -1338,7 +1541,6 @@ mod tests {
timeout_ms: 800,
debug: false,
allow_script_creatives: false,
- rendering_mode: ApsRenderingMode::TrustedServer,
inventory_domain: None,
inventory_page_origin: None,
}
@@ -1392,6 +1594,386 @@ mod tests {
})
}
+ fn drop_count(response: &AuctionResponse, reason: AuctionDropReason) -> u64 {
+ response.metadata["drop_reasons"][reason.as_str()]
+ .as_u64()
+ .unwrap_or_default()
+ }
+
+ #[derive(serde::Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ struct RendererCorpus {
+ publisher_origin: String,
+ base_descriptor: Json,
+ vectors: Vec,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct RendererCorpusVector {
+ id: String,
+ expected: String,
+ operation: Json,
+ }
+
+ fn corpus_value<'a>(operation: &'a Json, field: &str) -> &'a Json {
+ operation
+ .get(field)
+ .unwrap_or_else(|| panic!("should include corpus operation field {field}"))
+ }
+
+ fn corpus_string<'a>(operation: &'a Json, field: &str) -> &'a str {
+ corpus_value(operation, field)
+ .as_str()
+ .unwrap_or_else(|| panic!("corpus operation field {field} should be a string"))
+ }
+
+ fn corpus_usize(operation: &Json, field: &str) -> usize {
+ corpus_value(operation, field)
+ .as_u64()
+ .and_then(|value| usize::try_from(value).ok())
+ .unwrap_or_else(|| panic!("corpus operation field {field} should be a usize"))
+ }
+
+ fn set_json_path(root: &mut Json, path: &[Json], value: Json) {
+ let (segment, tail) = path
+ .split_first()
+ .expect("corpus JSON path should not be empty");
+ if tail.is_empty() {
+ if let Some(field) = segment.as_str() {
+ root.as_object_mut()
+ .expect("corpus string path should address an object")
+ .insert(field.to_string(), value);
+ } else {
+ let index = segment
+ .as_u64()
+ .and_then(|index| usize::try_from(index).ok())
+ .expect("corpus numeric path should be a usize");
+ let slot = root
+ .as_array_mut()
+ .and_then(|array| array.get_mut(index))
+ .expect("corpus numeric path should address an array element");
+ *slot = value;
+ }
+ return;
+ }
+
+ let child = if let Some(field) = segment.as_str() {
+ root.as_object_mut()
+ .and_then(|object| object.get_mut(field))
+ .expect("corpus string path should address an object field")
+ } else {
+ let index = segment
+ .as_u64()
+ .and_then(|index| usize::try_from(index).ok())
+ .expect("corpus numeric path should be a usize");
+ root.as_array_mut()
+ .and_then(|array| array.get_mut(index))
+ .expect("corpus numeric path should address an array element")
+ };
+ set_json_path(child, tail, value);
+ }
+
+ fn delete_json_path(root: &mut Json, path: &[Json]) {
+ let (segment, tail) = path
+ .split_first()
+ .expect("corpus JSON path should not be empty");
+ if tail.is_empty() {
+ let field = segment
+ .as_str()
+ .expect("corpus delete path should end in an object field");
+ root.as_object_mut()
+ .expect("corpus delete path should address an object")
+ .remove(field);
+ return;
+ }
+
+ let child = if let Some(field) = segment.as_str() {
+ root.as_object_mut()
+ .and_then(|object| object.get_mut(field))
+ .expect("corpus string path should address an object field")
+ } else {
+ let index = segment
+ .as_u64()
+ .and_then(|index| usize::try_from(index).ok())
+ .expect("corpus numeric path should be a usize");
+ root.as_array_mut()
+ .and_then(|array| array.get_mut(index))
+ .expect("corpus numeric path should address an array element")
+ };
+ delete_json_path(child, tail);
+ }
+
+ fn descriptor_field(descriptor: &mut Json, field: &str, value: Json) {
+ descriptor
+ .as_object_mut()
+ .expect("corpus descriptor should be an object")
+ .insert(field.to_string(), value);
+ }
+
+ fn materialize_renderer_corpus_vector(
+ corpus: &RendererCorpus,
+ vector: &RendererCorpusVector,
+ ) -> Json {
+ let mut descriptor = corpus.base_descriptor.clone();
+ let mut envelope: Json = serde_json::from_str(include_str!(
+ "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json"
+ ))
+ .expect("should parse shared APS renderer fixture");
+ let operation = &vector.operation;
+ let kind = corpus_string(operation, "kind");
+ let mut encoded_envelope = None;
+
+ match kind {
+ "none" => {}
+ "descriptor-delete" => {
+ descriptor
+ .as_object_mut()
+ .expect("corpus descriptor should be an object")
+ .remove(corpus_string(operation, "field"));
+ }
+ "descriptor-set" => descriptor_field(
+ &mut descriptor,
+ corpus_string(operation, "field"),
+ corpus_value(operation, "value").clone(),
+ ),
+ "descriptor-repeat" => {
+ let mut repeated =
+ corpus_string(operation, "unit").repeat(corpus_usize(operation, "count"));
+ if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) {
+ repeated.push_str(suffix);
+ }
+ descriptor_field(
+ &mut descriptor,
+ corpus_string(operation, "field"),
+ json!(repeated),
+ );
+ }
+ "bid-id-repeat" => {
+ let mut repeated =
+ corpus_string(operation, "unit").repeat(corpus_usize(operation, "count"));
+ if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) {
+ repeated.push_str(suffix);
+ }
+ descriptor_field(&mut descriptor, "bidId", json!(repeated));
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("id"),
+ ],
+ json!(repeated),
+ );
+ }
+ "dimension" => {
+ let field = corpus_string(operation, "field");
+ let envelope_field = match field {
+ "width" => "w",
+ "height" => "h",
+ _ => panic!("corpus dimension field should be width or height"),
+ };
+ let value = corpus_value(operation, "value").clone();
+ descriptor_field(&mut descriptor, field, value.clone());
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!(envelope_field),
+ ],
+ value,
+ );
+ }
+ "dimensions" => {
+ let width = corpus_value(operation, "width").clone();
+ let height = corpus_value(operation, "height").clone();
+ descriptor_field(&mut descriptor, "width", width.clone());
+ descriptor_field(&mut descriptor, "height", height.clone());
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("w"),
+ ],
+ width,
+ );
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("h"),
+ ],
+ height,
+ );
+ }
+ "creative-url" => {
+ let value = corpus_string(operation, "value").to_string();
+ descriptor_field(&mut descriptor, "creativeUrl", json!(value));
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("ext"),
+ json!("creativeurl"),
+ ],
+ json!(value),
+ );
+ }
+ "creative-url-bytes" => {
+ let prefix = "https://creative.example/";
+ let bytes = corpus_usize(operation, "bytes");
+ let value = format!(
+ "{prefix}{}",
+ "a".repeat(
+ bytes
+ .checked_sub(prefix.len())
+ .expect("corpus URL size should include its prefix")
+ )
+ );
+ descriptor_field(&mut descriptor, "creativeUrl", json!(value));
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("ext"),
+ json!("creativeurl"),
+ ],
+ json!(value),
+ );
+ }
+ "aax-literal" => {
+ encoded_envelope = Some(corpus_string(operation, "value").to_string());
+ }
+ "aax-bytes" => {
+ let bytes: Vec = corpus_value(operation, "values")
+ .as_array()
+ .expect("corpus byte vector should be an array")
+ .iter()
+ .map(|value| {
+ value
+ .as_u64()
+ .and_then(|value| u8::try_from(value).ok())
+ .expect("corpus byte vector should contain u8 values")
+ })
+ .collect();
+ encoded_envelope = Some(BASE64_STANDARD.encode(bytes));
+ }
+ "aax-raw-json" => {
+ encoded_envelope =
+ Some(BASE64_STANDARD.encode(corpus_string(operation, "value").as_bytes()));
+ }
+ "aax-decoded-bytes" => {
+ let mut serialized =
+ serde_json::to_string(&envelope).expect("should serialize corpus envelope");
+ let target = corpus_usize(operation, "bytes");
+ serialized.push_str(
+ &" ".repeat(
+ target
+ .checked_sub(serialized.len())
+ .expect("corpus decoded size should exceed fixture size"),
+ ),
+ );
+ encoded_envelope = Some(BASE64_STANDARD.encode(serialized.as_bytes()));
+ }
+ "aax-raw-price" => {
+ let serialized =
+ serde_json::to_string(&envelope).expect("should serialize corpus envelope");
+ let replacement = format!("\"price\":{}", corpus_string(operation, "value"));
+ let raw = serialized.replacen("\"price\":1.23", &replacement, 1);
+ assert_ne!(raw, serialized, "should replace the corpus fixture price");
+ encoded_envelope = Some(BASE64_STANDARD.encode(raw.as_bytes()));
+ }
+ "envelope-set" => {
+ let path = corpus_value(operation, "path")
+ .as_array()
+ .expect("corpus path should be an array");
+ set_json_path(
+ &mut envelope,
+ path,
+ corpus_value(operation, "value").clone(),
+ );
+ }
+ "envelope-delete" => {
+ let path = corpus_value(operation, "path")
+ .as_array()
+ .expect("corpus path should be an array");
+ delete_json_path(&mut envelope, path);
+ }
+ "duplicate-seat" => {
+ let seats = envelope
+ .get_mut("seatbid")
+ .and_then(Json::as_array_mut)
+ .expect("corpus fixture should contain a seat array");
+ let first = seats
+ .first()
+ .cloned()
+ .expect("corpus fixture should contain one seat");
+ seats.push(first);
+ }
+ "duplicate-bid" => {
+ let bids = envelope
+ .get_mut("seatbid")
+ .and_then(Json::as_array_mut)
+ .and_then(|seats| seats.first_mut())
+ .and_then(|seat| seat.get_mut("bid"))
+ .and_then(Json::as_array_mut)
+ .expect("corpus fixture should contain a bid array");
+ let first = bids
+ .first()
+ .cloned()
+ .expect("corpus fixture should contain one bid");
+ bids.push(first);
+ }
+ _ => panic!("unknown APS renderer corpus operation: {kind}"),
+ }
+
+ let encoded = encoded_envelope.unwrap_or_else(|| {
+ BASE64_STANDARD.encode(
+ serde_json::to_vec(&envelope).expect("should serialize corpus renderer envelope"),
+ )
+ });
+ descriptor_field(&mut descriptor, "aaxResponse", json!(encoded));
+ descriptor
+ }
+
+ #[test]
+ fn aps_renderer_matches_shared_cross_language_contract_corpus() {
+ let corpus: RendererCorpus = serde_json::from_str(include_str!(
+ "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json"
+ ))
+ .expect("should parse shared APS renderer corpus");
+ assert_eq!(
+ corpus.publisher_origin, "https://publisher.example",
+ "should pin the corpus publisher origin"
+ );
+
+ for vector in &corpus.vectors {
+ let descriptor = materialize_renderer_corpus_vector(&corpus, vector);
+ let actual = classify_aps_renderer_v1(&descriptor, &corpus.publisher_origin).as_str();
+ assert_eq!(
+ actual, vector.expected,
+ "should match APS renderer corpus vector {}",
+ vector.id
+ );
+ }
+ }
+
fn parse_with_context(
provider: &ApsAuctionProvider,
response: PlatformResponse,
@@ -1437,26 +2019,42 @@ mod tests {
}
#[test]
- fn config_accepts_canonical_alias_and_integer_ids() {
+ fn config_accepts_only_canonical_string_account_id() {
let canonical: ApsConfig = serde_json::from_value(json!({
"account_id": " example-account "
}))
.expect("should parse canonical account ID");
- let alias: ApsConfig =
- serde_json::from_value(json!({"pub_id": 1234})).expect("should parse legacy alias");
+ let integer = serde_json::from_value::(json!({"account_id": 1234}));
+ let legacy_alias = serde_json::from_value::(json!({
+ "pub_id": "legacy-account"
+ }));
+ let mixed_fields = serde_json::from_value::(json!({
+ "account_id": "example-account",
+ "pub_id": "legacy-account"
+ }));
let debug: ApsConfig = serde_json::from_value(json!({
"account_id": "example-account",
"debug": true
}))
.expect("should parse debug flag");
assert_eq!(canonical.account_id, "example-account");
- assert_eq!(alias.account_id, "1234");
- assert!(!canonical.enabled);
- assert!(!canonical.debug);
- assert!(debug.debug);
- assert!(!canonical.allow_script_creatives);
- assert_eq!(canonical.rendering_mode, ApsRenderingMode::TrustedServer);
- assert!(canonical.endpoint.ends_with("/e/pb/bid"));
+ assert!(
+ integer.is_err(),
+ "integer account IDs must fail the hard cutover"
+ );
+ assert!(
+ legacy_alias.is_err(),
+ "the legacy pub_id alias must fail the hard cutover"
+ );
+ assert!(
+ mixed_fields.is_err(),
+ "mixed account_id and pub_id fields must fail the hard cutover"
+ );
+ assert!(!canonical.enabled);
+ assert!(!canonical.debug);
+ assert!(debug.debug);
+ assert!(!canonical.allow_script_creatives);
+ assert!(canonical.endpoint.ends_with("/e/pb/bid"));
}
#[test]
@@ -1510,21 +2108,11 @@ mod tests {
)
.is_err()
);
- assert!(
- serde_json::from_value::(json!({
- "account_id": "one",
- "pub_id": "two"
- }))
- .is_err()
- );
- assert!(
- serde_json::from_value::(json!({
- "account_id": "example-account",
- "rendering_mode": "unsupported"
- }))
- .is_err(),
- "should reject an unknown APS rendering mode"
- );
+ let legacy_alias = serde_json::Value::Object(serde_json::Map::from_iter([(
+ ["pub", "id"].join("_"),
+ json!("two"),
+ )]));
+ assert!(serde_json::from_value::(legacy_alias).is_err());
for endpoint in [
"http://aps.example/e/pb/bid",
"https://",
@@ -1887,12 +2475,250 @@ mod tests {
let response = provider.parse_aps_response(&value, 12, &request());
assert!(response.bids.is_empty());
assert_eq!(
- response.metadata["drop_reasons"]["unexpected_response_shape"],
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
1
);
}
}
+ #[test]
+ fn upstream_bid_ids_are_required_bounded_control_free_and_response_unique() {
+ let provider = ApsAuctionProvider::new(config());
+ let mut missing = bid("missing", 1.0, "iframe");
+ missing
+ .as_object_mut()
+ .expect("should build an object bid")
+ .remove("id");
+ let empty = bid("", 1.1, "iframe");
+ let oversized = bid(&format!("{}x", "é".repeat(32)), 1.2, "iframe");
+ let control = bid("control\u{0000}id", 1.3, "iframe");
+ let duplicate_low = bid("duplicate", 1.4, "iframe");
+ let duplicate_high = bid("duplicate", 9.0, "iframe");
+ let valid_boundary = bid(&"é".repeat(32), 2.0, "iframe");
+
+ let response = provider.parse_aps_response(
+ &json!({"seatbid": [{"bid": [
+ missing,
+ empty,
+ oversized,
+ control,
+ duplicate_low,
+ duplicate_high,
+ valid_boundary
+ ]}]}),
+ 12,
+ &request(),
+ );
+
+ assert_eq!(response.status, BidStatus::Success);
+ assert_eq!(response.bids.len(), 1);
+ assert_eq!(
+ response.bids[0].bid_id.as_deref(),
+ Some("é".repeat(32).as_str())
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::MissingUpstreamBidId),
+ 2
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::UpstreamBidIdTooLarge),
+ 1
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidUpstreamBidId),
+ 1
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::DuplicateUpstreamBidId),
+ 2
+ );
+ }
+
+ #[test]
+ fn bid_validation_is_typed_and_isolated_from_a_valid_sibling() {
+ let provider = ApsAuctionProvider::new(config());
+ let mut unknown_imp = bid("unknown-imp", 1.0, "iframe");
+ unknown_imp["impid"] = json!("not-requested");
+ let negative_price = bid("negative-price", -0.1, "iframe");
+ let mut wrong_price = bid("wrong-price", 1.0, "iframe");
+ wrong_price["price"] = json!("1.0");
+ let mut zero_width = bid("zero-width", 1.0, "iframe");
+ zero_width["w"] = json!(0);
+ let mut over_height = bid("over-height", 1.0, "iframe");
+ over_height["h"] = json!(4097);
+ let mut unmatched_size = bid("unmatched-size", 1.0, "iframe");
+ unmatched_size["w"] = json!(728);
+ unmatched_size["h"] = json!(90);
+ let mut missing_url = bid("missing-url", 1.0, "iframe");
+ missing_url["ext"]
+ .as_object_mut()
+ .expect("should build a bid extension")
+ .remove("creativeurl");
+ let mut invalid_url = bid("invalid-url", 1.0, "iframe");
+ invalid_url["ext"]["creativeurl"] = json!("http://creative.example/render");
+ let invalid_tag = bid("invalid-tag", 1.0, "video");
+ let mut invalid_creative_id = bid("invalid-creative-id", 1.0, "iframe");
+ invalid_creative_id["crid"] = json!(42);
+ let mut unsupported_media = bid("unsupported-media", 1.0, "iframe");
+ unsupported_media["mtype"] = json!(2);
+ let valid = bid("valid-sibling", 2.0, "iframe");
+
+ let response = provider.parse_aps_response(
+ &json!({"seatbid": [{"bid": [
+ "malformed",
+ unknown_imp,
+ negative_price,
+ wrong_price,
+ zero_width,
+ over_height,
+ unmatched_size,
+ missing_url,
+ invalid_url,
+ invalid_tag,
+ invalid_creative_id,
+ unsupported_media,
+ valid
+ ]}]}),
+ 12,
+ &request(),
+ );
+
+ assert_eq!(response.bids.len(), 1);
+ assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid-sibling"));
+ for reason in [
+ AuctionDropReason::MalformedBid,
+ AuctionDropReason::UnknownImpression,
+ AuctionDropReason::DimensionsOutOfRange,
+ AuctionDropReason::MissingCreativeUrl,
+ AuctionDropReason::InvalidCreativeUrl,
+ AuctionDropReason::InvalidTagType,
+ AuctionDropReason::InvalidCreativeId,
+ AuctionDropReason::UnsupportedMediaType,
+ ] {
+ assert_eq!(drop_count(&response, reason), 1, "should report {reason:?}");
+ }
+ assert_eq!(drop_count(&response, AuctionDropReason::InvalidPrice), 2);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidDimensions),
+ 2
+ );
+ }
+
+ #[test]
+ fn dimensions_accept_exact_requested_membership_at_contract_boundaries() {
+ let provider = ApsAuctionProvider::new(config());
+ let mut auction_request = request();
+ auction_request.slots = vec![
+ AdSlot {
+ id: "minimum-slot".to_string(),
+ formats: vec![AdFormat {
+ media_type: MediaType::Banner,
+ width: 1,
+ height: 1,
+ }],
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ },
+ AdSlot {
+ id: "maximum-slot".to_string(),
+ formats: vec![AdFormat {
+ media_type: MediaType::Banner,
+ width: 4096,
+ height: 4096,
+ }],
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ },
+ ];
+ let mut minimum = bid("minimum", 1.0, "iframe");
+ minimum["impid"] = json!("minimum-slot");
+ minimum["w"] = json!(1);
+ minimum["h"] = json!(1);
+ let mut maximum = bid("maximum", 1.0, "iframe");
+ maximum["impid"] = json!("maximum-slot");
+ maximum["w"] = json!(4096);
+ maximum["h"] = json!(4096);
+
+ let response = provider.parse_aps_response(
+ &json!({"seatbid": [{"bid": [minimum, maximum]}]}),
+ 12,
+ &auction_request,
+ );
+
+ assert_eq!(response.status, BidStatus::Success);
+ assert_eq!(response.bids.len(), 2);
+ assert_eq!(response.bids[0].slot_id, "minimum-slot");
+ assert_eq!(response.bids[1].slot_id, "maximum-slot");
+ assert_eq!(response.metadata["dropped_bid_count"], 0);
+ }
+
+ #[test]
+ fn contextual_isolated_from_valid_bids_while_currency_and_nonfinite_json_are_global() {
+ let provider = ApsAuctionProvider::new(config());
+ let contextual = provider.parse_aps_response(
+ &json!({"contextual": {"slots": []}, "seatbid": [{"bid": [bid("valid", 1.0, "iframe")]}]}),
+ 12,
+ &request(),
+ );
+ assert_eq!(contextual.status, BidStatus::Success);
+ assert_eq!(contextual.bids.len(), 1);
+ assert_eq!(
+ drop_count(&contextual, AuctionDropReason::InvalidProviderResponse),
+ 1
+ );
+
+ let value = json!({
+ "cur": "EUR",
+ "seatbid": [{"bid": [bid("eur", 1.0, "iframe")]}]
+ });
+ let response = provider.parse_aps_response(&value, 12, &request());
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
+ 1
+ );
+
+ let body = br#"{"seatbid":[{"bid":[{"id":"overflow","impid":"fictional-slot","price":1e400,"w":300,"h":250,"ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"}}]}]}"#;
+ let response = futures::executor::block_on(
+ provider.parse_response_inner(
+ PlatformResponse::new(
+ edgezero_core::http::response_builder()
+ .status(StatusCode::OK)
+ .body(EdgeBody::from(body.to_vec()))
+ .expect("should build nonfinite APS response"),
+ ),
+ 12,
+ Some(&request()),
+ None,
+ false,
+ ),
+ )
+ .expect("should reject nonfinite APS JSON safely");
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
+ 1
+ );
+ }
+
+ #[test]
+ fn invalid_only_bid_retains_its_attributable_slot_failure() {
+ let provider = ApsAuctionProvider::new(config());
+ let mut invalid = bid("invalid", 1.0, "iframe");
+ invalid["ext"]["creativeurl"] = json!("http://creative.example/render");
+
+ let response =
+ provider.parse_aps_response(&json!({"seatbid": [{"bid": [invalid]}]}), 12, &request());
+
+ assert_eq!(response.status, BidStatus::NoBid);
+ assert_eq!(
+ response.metadata["invalid_slots"]["fictional-slot"],
+ "invalid_provider_response"
+ );
+ }
+
#[test]
fn debug_metadata_matches_pbs_httpcalls_shape() {
let mut provider_config = config();
@@ -2080,7 +2906,7 @@ mod tests {
let oversized = parse_with_context(&provider, oversized);
assert_eq!(
- malformed.metadata["drop_reasons"]["unexpected_response_shape"],
+ drop_count(&malformed, AuctionDropReason::InvalidProviderResponse),
1
);
assert_eq!(
@@ -2160,7 +2986,7 @@ mod tests {
assert!(response.bids.is_empty());
assert_eq!(
- response.metadata["drop_reasons"]["unexpected_response_shape"],
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
1
);
}
@@ -2240,15 +3066,18 @@ mod tests {
}
#[test]
- fn unsupported_currency_is_a_no_bid() {
+ fn unsupported_currency_is_an_invalid_provider_response() {
let provider = ApsAuctionProvider::new(config());
let response = provider.parse_aps_response(
&json!({"cur": "EUR", "seatbid": [{"bid": [bid("eur-bid", 1.0, "iframe")]}]}),
12,
&request(),
);
- assert_eq!(response.status, BidStatus::NoBid);
- assert_eq!(response.metadata["drop_reasons"]["unsupported_currency"], 1);
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
+ 1
+ );
}
#[test]
@@ -2261,10 +3090,7 @@ mod tests {
);
assert_eq!(response.bids.len(), 1);
assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid"));
- assert_eq!(
- response.metadata["drop_reasons"]["missing_render_source"],
- 1
- );
+ assert_eq!(drop_count(&response, AuctionDropReason::MalformedBid), 1);
}
#[test]
@@ -2340,7 +3166,7 @@ mod tests {
);
assert!(response.bids.is_empty());
assert_eq!(
- response.metadata["drop_reasons"]["missing_render_source"],
+ drop_count(&response, AuctionDropReason::MissingCreativeUrl),
1
);
assert_eq!(response.metadata["drop_reasons"]["invalid_dimensions"], 1);
@@ -2367,6 +3193,8 @@ mod tests {
let mut uppercase_publisher = request();
uppercase_publisher.publisher.domain = "Creative.Example".to_string();
+ uppercase_publisher.publisher.page_url =
+ Some("https://Creative.Example/article".to_string());
let response = provider.parse_aps_response(
&json!({"seatbid": [{"bid": [bid("same-origin", 1.0, "iframe")]}]}),
12,
@@ -2377,49 +3205,7 @@ mod tests {
}
#[test]
- fn registers_and_serves_only_static_renderer_route() {
- let integration = ApsRendererIntegration {
- rendering_mode: ApsRenderingMode::TrustedServer,
- };
- let routes = integration.routes();
- assert_eq!(routes.len(), 1, "should register one route");
- assert_eq!(routes[0].method, Method::GET);
- assert_eq!(routes[0].path, APS_RENDERER_ROUTE);
-
- let settings = create_test_settings();
- let services = noop_services();
- let request = http::Request::builder()
- .method(Method::GET)
- .uri(APS_RENDERER_ROUTE)
- .body(EdgeBody::empty())
- .expect("should build renderer request");
- let response =
- futures::executor::block_on(integration.handle(&settings, &services, request))
- .expect("should serve renderer");
- assert_eq!(response.status(), StatusCode::OK);
- assert_eq!(
- response.headers()[header::CONTENT_TYPE],
- "text/html; charset=utf-8"
- );
- assert_eq!(response.headers()["x-content-type-options"], "nosniff");
- assert_eq!(response.headers()["referrer-policy"], "no-referrer");
- assert_eq!(
- response.headers()[header::CONTENT_SECURITY_POLICY],
- APS_RENDERER_CSP
- );
-
- let post = http::Request::builder()
- .method(Method::POST)
- .uri(APS_RENDERER_ROUTE)
- .body(EdgeBody::empty())
- .expect("should build method rejection request");
- let response = futures::executor::block_on(integration.handle(&settings, &services, post))
- .expect("should reject unsupported method");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- }
-
- #[test]
- fn enabled_config_registers_renderer_proxy() {
+ fn enabled_config_does_not_register_a_second_renderer_proxy() {
let mut settings = create_test_settings();
settings
.integrations
@@ -2434,81 +3220,37 @@ mod tests {
.expect("should return enabled registration");
assert_eq!(registration.integration_id, APS_INTEGRATION_ID);
- assert_eq!(registration.proxies.len(), 1);
- assert_eq!(registration.head_injectors.len(), 1);
- let document_state = IntegrationDocumentState::default();
- let context = IntegrationHtmlContext {
- request_host: "publisher.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
- assert!(
- registration.head_injectors[0]
- .head_inserts(&context)
- .is_empty(),
- "should not inject a native-mode head marker by default"
- );
- assert!(
- registration.head_injectors[0]
- .tsjs_script_tag_attributes()
- .is_empty(),
- "should not authorize native rendering by default"
- );
+ assert!(registration.proxies.is_empty());
assert!(registration.js_disabled);
}
#[test]
- fn publisher_native_config_registers_runner_mode_without_renderer_route() {
+ fn config_without_enabled_registers_neither_integration_nor_provider() {
let mut settings = create_test_settings();
settings
.integrations
.insert_config(
APS_INTEGRATION_ID,
- &json!({
- "enabled": true,
- "account_id": "example-account",
- "rendering_mode": "publisher_native"
- }),
+ &json!({"account_id": "example-account"}),
)
- .expect("should insert native APS config");
+ .expect("should insert default-disabled APS config");
- let registration = register(&settings)
- .expect("should register APS")
- .expect("should return enabled registration");
assert!(
- registration.proxies.is_empty(),
- "should not register the static renderer"
+ register(&settings)
+ .expect("should evaluate APS integration registration")
+ .is_none(),
+ "omitted enabled should not register APS browser routes"
);
- assert_eq!(registration.head_injectors.len(), 1);
-
- let integration = ApsRendererIntegration {
- rendering_mode: ApsRenderingMode::PublisherNative,
- };
assert!(
- integration.routes().is_empty(),
- "should expose no renderer route"
- );
- let document_state = IntegrationDocumentState::default();
- let context = IntegrationHtmlContext {
- request_host: "publisher.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
- assert!(
- integration.head_inserts(&context).is_empty(),
- "should not inject a forgeable native-mode marker"
- );
- assert_eq!(
- integration.tsjs_script_tag_attributes(),
- vec![("data-ts-aps-rendering-mode", "publisher_native")],
- "should authorize native mode on the publisher bundle tag"
+ register_providers(&settings)
+ .expect("should evaluate APS provider registration")
+ .is_empty(),
+ "omitted enabled should not register the APS auction provider"
);
}
#[test]
- fn publisher_native_script_creatives_remain_available_for_controlled_validation() {
+ fn enabled_invalid_config_fails_provider_registration() {
let mut settings = create_test_settings();
settings
.integrations
@@ -2517,99 +3259,504 @@ mod tests {
&json!({
"enabled": true,
"account_id": "example-account",
- "allow_script_creatives": true,
- "rendering_mode": "publisher_native"
+ "endpoint": "http://insecure.example/openrtb"
}),
)
- .expect("should insert native APS script config");
+ .expect("should insert invalid APS config for startup validation");
- let providers = register_providers(&settings).expect("should register APS provider");
+ let _error = register_providers(&settings)
+ .err()
+ .expect("should reject invalid enabled APS configuration");
+ }
+ #[test]
+ fn coordinated_cutover_routes_are_reserved_with_exact_local_method_policy() {
+ let enabled = ApsV1Integration { enabled: true };
+ let disabled = ApsV1Integration { enabled: false };
+ let settings = create_test_settings();
+ let services = noop_services();
+
+ for path in [APS_RENDERER_V2_ROUTE, APS_RUNNER_ROUTE] {
+ let disabled_get = http::Request::builder()
+ .method(Method::GET)
+ .uri(path)
+ .body(EdgeBody::empty())
+ .expect("should build disabled APS request");
+ let response =
+ futures::executor::block_on(disabled.handle(&settings, &services, disabled_get))
+ .expect("disabled APS family should answer locally");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+
+ let disabled_post = http::Request::builder()
+ .method(Method::POST)
+ .uri(path)
+ .body(EdgeBody::empty())
+ .expect("should build disabled APS POST request");
+ let response =
+ futures::executor::block_on(disabled.handle(&settings, &services, disabled_post))
+ .expect("disabled APS family should remain absent for every method");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ assert!(!response.headers().contains_key(header::ALLOW));
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+
+ for method in [
+ Method::HEAD,
+ Method::OPTIONS,
+ Method::PUT,
+ Method::PATCH,
+ Method::DELETE,
+ Method::TRACE,
+ Method::CONNECT,
+ Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"),
+ ] {
+ let request = http::Request::builder()
+ .method(method.clone())
+ .uri(path)
+ .body(EdgeBody::empty())
+ .expect("should build APS method rejection");
+ let response =
+ futures::executor::block_on(enabled.handle(&settings, &services, request))
+ .expect("reserved APS family should reject unsupported methods locally");
+ assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
+ assert_eq!(response.headers()[header::ALLOW], "GET");
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+ assert_eq!(response.headers().len(), 2, "method={method} path={path}");
+ assert!(
+ response
+ .into_body()
+ .into_bytes()
+ .unwrap_or_default()
+ .is_empty()
+ );
+ }
+ }
+
+ for path in [
+ "/integrations/aps/renderer",
+ "/integrations/aps/renderer/v1",
+ "/integrations/aps/runner/v1.js",
+ "/integrations/aps/renderer/v2/extra",
+ "/integrations/aps/not-a-route",
+ ] {
+ let request = http::Request::builder()
+ .method(Method::GET)
+ .uri(path)
+ .body(EdgeBody::empty())
+ .expect("should build unknown APS family request");
+ let response =
+ futures::executor::block_on(enabled.handle(&settings, &services, request))
+ .expect("unknown APS family path should answer locally");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND, "path={path}");
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+ }
+ }
+
+ #[test]
+ fn aps_family_classifier_has_an_exact_segment_boundary() {
+ assert!(is_aps_family_path("/integrations/aps"));
+ assert!(is_aps_family_path("/integrations/aps/renderer/v2"));
+ assert!(!is_aps_family_path("/integrations/apsx"));
+ assert!(!is_aps_family_path("/integrations/ap"));
+ }
+
+ #[test]
+ fn coordinated_cutover_renderer_has_exact_immutable_embedding_policy() {
+ let integration = ApsV1Integration { enabled: true };
+ let request = http::Request::builder()
+ .method(Method::GET)
+ .uri(APS_RENDERER_V2_ROUTE)
+ .body(EdgeBody::empty())
+ .expect("should build versioned renderer request");
+ let response = futures::executor::block_on(integration.handle(
+ &create_test_settings(),
+ &noop_services(),
+ request,
+ ))
+ .expect("versioned renderer should be served");
+
+ assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
- providers.len(),
- 1,
- "should retain the controlled experiment"
+ response.headers()[header::CONTENT_TYPE],
+ "text/html; charset=utf-8"
+ );
+ assert_eq!(
+ response.headers()[header::CACHE_CONTROL],
+ "public, max-age=31536000, immutable"
+ );
+ assert_eq!(response.headers()["x-content-type-options"], "nosniff");
+ assert_eq!(response.headers()["referrer-policy"], "no-referrer");
+ assert_eq!(
+ response.headers()[header::CONTENT_SECURITY_POLICY],
+ APS_RENDERER_V2_CSP
+ );
+ assert!(!response.headers().contains_key("x-frame-options"));
+ assert_eq!(
+ APS_RENDERER_V2_CSP,
+ "default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' http: https:; connect-src http: https:; frame-src data: https:; img-src data: blob: http: https:; media-src blob: http: https:; style-src 'unsafe-inline' http: https:; font-src data: http: https:; worker-src blob: http: https:; frame-ancestors 'self'; form-action https:;"
+ );
+ assert_eq!(
+ APS_RENDERER_SANDBOX,
+ "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"
);
+
+ let body = futures::executor::block_on(
+ response
+ .into_body()
+ .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES),
+ )
+ .expect("renderer body should stay within the runner cap");
+ assert_eq!(body.as_ref(), APS_RENDERER_V2_DOCUMENT.as_bytes());
+ assert!(APS_RENDERER_V2_DOCUMENT.contains("TS APS Bootstrap Ready"));
+ assert!(APS_RENDERER_V2_DOCUMENT.contains("TS APS Bootstrap Configure"));
+ assert!(APS_RENDERER_V2_DOCUMENT.contains("data:text/html;charset=utf-8,"));
+ assert!(APS_RENDERER_V2_DOCUMENT.contains("/integrations/aps/runner.js"));
+ for forbidden in [
+ "client.aps.amazon-adsystem.com",
+ "example-account",
+ "bid-123",
+ "creative.example/render",
+ ] {
+ assert!(
+ !APS_RENDERER_V2_DOCUMENT.contains(forbidden),
+ "renderer bootstrap should not contain a concrete descriptor or vendored runner value: {forbidden}"
+ );
+ }
+ }
+
+ fn raw_runner_response(
+ body: impl Into>,
+ content_type: ProxyHeaderEvidenceV1,
+ content_encoding: ProxyHeaderEvidenceV1,
+ content_length: ProxyHeaderEvidenceV1,
+ ) -> RawProxyResponseV1 {
+ RawProxyResponseV1 {
+ evidence: ProxyResponseEvidenceV1 {
+ status: 200,
+ content_type,
+ content_encoding,
+ content_length,
+ },
+ body: body.into(),
+ }
+ }
+
+ fn request_runner(
+ stub: &Arc,
+ ) -> Result, Report> {
+ let services = build_services_with_http_client(
+ Arc::clone(stub) as Arc
+ );
+ let request = http::Request::builder()
+ .method(Method::GET)
+ .uri(APS_RUNNER_ROUTE)
+ .body(EdgeBody::empty())
+ .expect("should build APS runner request");
+ futures::executor::block_on(ApsV1Integration { enabled: true }.handle(
+ &create_test_settings(),
+ &services,
+ request,
+ ))
}
#[test]
- fn config_without_enabled_does_not_register_provider_or_renderer() {
- let mut settings = create_test_settings();
- settings
- .integrations
- .insert_config(
- APS_INTEGRATION_ID,
- &json!({"account_id": "example-account"}),
- )
- .expect("should insert default-disabled APS config");
+ fn coordinated_cutover_runner_proxies_exact_valid_identity_bytes() {
+ let body = b"window.fictionalApsRunner = true;".to_vec();
+ let stub = Arc::new(StubHttpClient::new());
+ stub.push_raw_proxy_response(raw_runner_response(
+ body.clone(),
+ ProxyHeaderEvidenceV1::one("application/javascript; charset=utf-8"),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one(body.len().to_string()),
+ ));
- assert!(
- register(&settings)
- .expect("should evaluate renderer registration")
- .is_none(),
- "omitted enabled should not register the renderer route"
+ let response = request_runner(&stub).expect("valid runner should be proxied");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.headers()[header::CONTENT_TYPE],
+ "application/javascript; charset=utf-8"
);
- assert!(
- register_providers(&settings)
- .expect("should evaluate provider registration")
- .is_empty(),
- "omitted enabled should not register the auction provider"
+ assert_eq!(response.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*");
+ assert_eq!(
+ response.headers()["cross-origin-resource-policy"],
+ "cross-origin"
+ );
+ assert_eq!(response.headers()["x-content-type-options"], "nosniff");
+ assert_eq!(response.headers()["referrer-policy"], "no-referrer");
+ assert_eq!(response.headers().len(), 5);
+ let returned = futures::executor::block_on(
+ response
+ .into_body()
+ .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES),
+ )
+ .expect("valid runner body should remain bounded");
+ assert_eq!(returned.as_ref(), body.as_slice());
+
+ assert_eq!(stub.recorded_backend_names(), vec!["stub-backend"]);
+ assert_eq!(stub.recorded_request_uris(), vec![APS_RUNNER_UPSTREAM_URL]);
+ assert_eq!(stub.recorded_request_methods(), vec!["GET"]);
+ assert_eq!(
+ stub.recorded_request_headers(),
+ vec![vec![(
+ "accept-encoding".to_string(),
+ "identity".to_string()
+ )]]
+ );
+ assert_eq!(stub.recorded_request_bodies(), vec![Vec::::new()]);
+ assert_eq!(APS_RUNNER_TOTAL_TIMEOUT, Duration::from_secs(5));
+ assert_eq!(
+ stub.recorded_raw_proxy_policies(),
+ vec![RawProxyPolicyV1 {
+ total_timeout: APS_RUNNER_TOTAL_TIMEOUT,
+ first_byte_timeout: Duration::from_secs(4),
+ blocking_read_timeout: Duration::from_millis(250),
+ max_response_bytes: APS_RUNNER_MAX_RESPONSE_BYTES,
+ }]
);
}
#[test]
- fn enabled_invalid_config_fails_provider_registration() {
- let mut settings = create_test_settings();
- settings
- .integrations
- .insert_config(
- APS_INTEGRATION_ID,
- &json!({
- "enabled": true,
- "account_id": "example-account",
- "endpoint": "http://insecure.example/openrtb"
- }),
+ fn coordinated_cutover_runner_rejects_ambiguous_or_invalid_upstream_evidence() {
+ let valid_body = b"window.fictionalApsRunner = true;".to_vec();
+ let valid_length = valid_body.len().to_string();
+ let cases = [
+ (
+ "redirect",
+ RawProxyResponseV1 {
+ evidence: ProxyResponseEvidenceV1 {
+ status: 302,
+ content_type: ProxyHeaderEvidenceV1::one("application/javascript"),
+ content_encoding: ProxyHeaderEvidenceV1::absent(),
+ content_length: ProxyHeaderEvidenceV1::one(valid_length.clone()),
+ },
+ body: valid_body.clone(),
+ },
+ ),
+ (
+ "missing content type",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one(valid_length.clone()),
+ ),
+ ),
+ (
+ "duplicate content type",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::Occurrences(vec![
+ b"application/javascript".to_vec(),
+ b"text/javascript".to_vec(),
+ ]),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one(valid_length.clone()),
+ ),
+ ),
+ (
+ "combined content type list",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::Combined(
+ b"application/javascript, text/javascript".to_vec(),
+ ),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one(valid_length.clone()),
+ ),
+ ),
+ (
+ "wrong charset",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::one("application/javascript; charset=iso-8859-1"),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one(valid_length.clone()),
+ ),
+ ),
+ (
+ "encoded body",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::one("gzip"),
+ ProxyHeaderEvidenceV1::one(valid_length.clone()),
+ ),
+ ),
+ (
+ "unavailable encoding evidence",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::Unavailable,
+ ProxyHeaderEvidenceV1::one(valid_length.clone()),
+ ),
+ ),
+ (
+ "leading-zero length",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one(format!("0{valid_length}")),
+ ),
+ ),
+ (
+ "mismatched length",
+ raw_runner_response(
+ valid_body.clone(),
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one("1"),
+ ),
+ ),
+ (
+ "invalid utf-8",
+ raw_runner_response(
+ vec![0xff],
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one("1"),
+ ),
+ ),
+ ];
+
+ for (name, response) in cases {
+ let stub = Arc::new(StubHttpClient::new());
+ stub.push_raw_proxy_response(response);
+ let response = request_runner(&stub).expect("invalid runner should fail locally");
+ assert_eq!(response.status(), StatusCode::BAD_GATEWAY, "case={name}");
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+ assert_eq!(response.headers().len(), 1, "case={name}");
+ let body = futures::executor::block_on(
+ response
+ .into_body()
+ .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES),
)
- .expect("should insert invalid APS config for startup validation");
+ .expect("local failure body should be bounded");
+ assert!(body.is_empty(), "case={name}");
+ }
+ }
- let _error = register_providers(&settings)
- .err()
- .expect("should reject invalid enabled APS configuration");
+ #[test]
+ fn coordinated_cutover_runner_header_grammars_are_closed_and_complete() {
+ for content_type in [
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::one("text/javascript"),
+ ProxyHeaderEvidenceV1::one(" Application/JavaScript ; Charset = UTF-8 "),
+ ProxyHeaderEvidenceV1::Combined(b"text/javascript; charset=utf-8".to_vec()),
+ ] {
+ assert!(
+ ApsV1Integration::validate_runner_content_type(&content_type).is_ok(),
+ "accepted content type: {content_type:?}"
+ );
+ }
+ for content_type in [
+ ProxyHeaderEvidenceV1::Unavailable,
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one("application/ecmascript"),
+ ProxyHeaderEvidenceV1::one("application/javascript; charset=\"utf-8\""),
+ ProxyHeaderEvidenceV1::one("application/javascript; charset=utf-8; level=1"),
+ ProxyHeaderEvidenceV1::one("application/javascript; boundary=x"),
+ ProxyHeaderEvidenceV1::one("application/javascript,"),
+ ProxyHeaderEvidenceV1::one("application/javascript\u{a0}"),
+ ] {
+ assert!(
+ ApsV1Integration::validate_runner_content_type(&content_type).is_err(),
+ "rejected content type: {content_type:?}"
+ );
+ }
+
+ for encoding in [
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::one("identity"),
+ ProxyHeaderEvidenceV1::Combined(b" IDENTITY\t".to_vec()),
+ ] {
+ assert!(
+ ApsV1Integration::validate_runner_content_encoding(&encoding).is_ok(),
+ "accepted encoding: {encoding:?}"
+ );
+ }
+ for encoding in [
+ ProxyHeaderEvidenceV1::Unavailable,
+ ProxyHeaderEvidenceV1::one(""),
+ ProxyHeaderEvidenceV1::one("gzip"),
+ ProxyHeaderEvidenceV1::one("identity, identity"),
+ ProxyHeaderEvidenceV1::Occurrences(vec![b"identity".to_vec(), b"identity".to_vec()]),
+ ] {
+ assert!(
+ ApsV1Integration::validate_runner_content_encoding(&encoding).is_err(),
+ "rejected encoding: {encoding:?}"
+ );
+ }
+
+ assert_eq!(
+ ApsV1Integration::validate_runner_content_length(&ProxyHeaderEvidenceV1::absent()),
+ Ok(None)
+ );
+ assert_eq!(
+ ApsV1Integration::validate_runner_content_length(&ProxyHeaderEvidenceV1::one("0")),
+ Ok(Some(0))
+ );
+ assert_eq!(
+ ApsV1Integration::validate_runner_content_length(&ProxyHeaderEvidenceV1::Combined(
+ APS_RUNNER_MAX_RESPONSE_BYTES.to_string().into_bytes()
+ )),
+ Ok(Some(APS_RUNNER_MAX_RESPONSE_BYTES))
+ );
+ for length in [
+ ProxyHeaderEvidenceV1::Unavailable,
+ ProxyHeaderEvidenceV1::one(""),
+ ProxyHeaderEvidenceV1::one("00"),
+ ProxyHeaderEvidenceV1::one("01"),
+ ProxyHeaderEvidenceV1::one("+1"),
+ ProxyHeaderEvidenceV1::one(" 1"),
+ ProxyHeaderEvidenceV1::one("1 "),
+ ProxyHeaderEvidenceV1::one("1, 1"),
+ ProxyHeaderEvidenceV1::one((APS_RUNNER_MAX_RESPONSE_BYTES + 1).to_string()),
+ ProxyHeaderEvidenceV1::Occurrences(vec![b"1".to_vec(), b"1".to_vec()]),
+ ] {
+ assert!(
+ ApsV1Integration::validate_runner_content_length(&length).is_err(),
+ "rejected length: {length:?}"
+ );
+ }
}
#[test]
- fn renderer_document_is_static_and_nonce_bound() {
- assert!(APS_RENDERER_DOCUMENT.contains("^#tsaps="));
- assert!(APS_RENDERER_DOCUMENT.contains("event.source!==parent"));
- assert!(APS_RENDERER_DOCUMENT.contains("message.nonce!==expected"));
- assert!(APS_RENDERER_DOCUMENT.contains("prebid/creative/render"));
- assert!(APS_RENDERER_DOCUMENT.contains("window._aps instanceof Map"));
- assert!(
- APS_RENDERER_DOCUMENT
- .contains("html,body{margin:0;padding:0}body>iframe{display:block}")
+ fn coordinated_cutover_runner_accepts_exact_cap_and_rejects_one_byte_over() {
+ let exact = raw_runner_response(
+ vec![b' '; APS_RUNNER_MAX_RESPONSE_BYTES],
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::absent(),
);
- assert!(APS_RENDERER_DOCUMENT.contains("store:new Map([['listeners',new Map()]])"));
- assert!(APS_RENDERER_DOCUMENT.contains("account.queue.push(new CustomEvent"));
- assert!(
- APS_RENDERER_DOCUMENT.contains("trusted-server/aps/renderer-ready")
- && APS_RENDERER_DOCUMENT.contains("trusted-server/aps/renderer-failed")
+ assert!(ApsV1Integration::validate_runner_response(&exact).is_ok());
+
+ let over = raw_runner_response(
+ vec![b' '; APS_RUNNER_MAX_RESPONSE_BYTES + 1],
+ ProxyHeaderEvidenceV1::one("application/javascript"),
+ ProxyHeaderEvidenceV1::absent(),
+ ProxyHeaderEvidenceV1::absent(),
);
- assert!(!APS_RENDERER_DOCUMENT.contains("window.apstag"));
- assert!(
- APS_RENDERER_DOCUMENT
- .contains("https://client.aps.amazon-adsystem.com/prebid-creative.js")
- );
- assert!(!APS_RENDERER_DOCUMENT.contains(""#
- )]
- }
-}
-
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
- use crate::integrations::{IntegrationDocumentState, IntegrationRegistry};
+ use crate::integrations::IntegrationRegistry;
use crate::platform::test_support::{StubHttpClient, build_services_with_http_client};
use crate::test_support::tests::create_test_settings;
use http::Method;
@@ -554,26 +532,26 @@ mod tests {
}
#[test]
- fn head_injector_emits_proxy_path() {
- let custom_config = DidomiIntegrationConfig {
- enabled: true,
- proxy_path: Some("my-consent".to_string()),
- sdk_origin: default_sdk_origin(),
- api_origin: default_api_origin(),
- };
- let integration = DidomiIntegration::new(Arc::new(custom_config));
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let inserts = integration.head_inserts(&ctx);
- assert_eq!(inserts.len(), 1);
+ fn registration_projects_custom_proxy_path_without_a_config_script() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ DIDOMI_INTEGRATION_ID,
+ &serde_json::json!({
+ "enabled": true,
+ "proxy_path": "my-consent",
+ }),
+ )
+ .expect("should insert Didomi config");
+ let registration = register(&settings)
+ .expect("config should parse")
+ .expect("Didomi should register");
+
+ assert!(registration.head_injectors.is_empty());
assert_eq!(
- inserts[0],
- r#""#
+ registration.browser_config_v1,
+ Some(serde_json::json!({ "proxyPath": "/my-consent/" }))
);
}
@@ -636,19 +614,24 @@ mod tests {
}
#[test]
- fn head_injector_default_path() {
- let integration = DidomiIntegration::new(Arc::new(config(true)));
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let inserts = integration.head_inserts(&ctx);
+ fn registration_projects_default_proxy_path() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ DIDOMI_INTEGRATION_ID,
+ &serde_json::json!({ "enabled": true }),
+ )
+ .expect("should insert Didomi config");
+ let registration = register(&settings)
+ .expect("config should parse")
+ .expect("Didomi should register");
+
assert_eq!(
- inserts[0],
- r#""#
+ registration.browser_config_v1,
+ Some(serde_json::json!({
+ "proxyPath": "/integrations/didomi/consent/"
+ }))
);
}
}
diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html
new file mode 100644
index 000000000..3b9e3fc9a
--- /dev/null
+++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html
@@ -0,0 +1,126 @@
+
+
+
diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js
new file mode 100644
index 000000000..5c8a161f1
--- /dev/null
+++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js
@@ -0,0 +1,138 @@
+// @generated by scripts/generate-aps-renderer-contract.mjs
+// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027
+// corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9
+var DESCRIPTOR_KEYS = ["aaxResponse","accountId","bidId","creativeUrl","height","tagType","type","version","width"];
+var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ["aaxResponse","accountId","bidId","creativeId","creativeUrl","height","tagType","type","version","width"];
+var ENVELOPE_ROOT_KEYS = ["seatbid"];
+var ENVELOPE_SEAT_KEYS = ["bid"];
+var ENVELOPE_BID_KEYS = ["ext","h","id","price","w"];
+var ENVELOPE_EXT_KEYS = ["creativeurl","tagtype"];
+var MAX_ACCOUNT_ID_BYTES = 1024;
+var MAX_BID_ID_BYTES = 64;
+var MAX_CREATIVE_ID_BYTES = 1024;
+var MAX_CREATIVE_URL_BYTES = 4096;
+var MAX_RENDER_ENVELOPE_BYTES = 262144;
+var MAX_RENDER_ENVELOPE_BASE64_BYTES = 349528;
+var STANDARD_BASE64_PATTERN = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$";
+var RENDER_DIMENSION_MIN = 1;
+var RENDER_DIMENSION_MAX = 4096;
+function apsExactRecord(value, expectedKeys) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ var prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) return false;
+ if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false;
+ var actual = Object.getOwnPropertyNames(value).sort();
+ if (actual.length !== expectedKeys.length) return false;
+ for (var index = 0; index < actual.length; index += 1) {
+ var propertyName = actual[index];
+ if (propertyName === undefined || propertyName !== expectedKeys[index]) return false;
+ var property = Object.getOwnPropertyDescriptor(value, propertyName);
+ if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false;
+ }
+ return true;
+}
+
+function apsUtf8Length(value) {
+ return (new TextEncoder()).encode(value).length;
+}
+
+function apsHasAsciiControl(value) {
+ return /[\x00-\x1f\x7f]/.test(value);
+}
+
+function apsDimensionResult(value) {
+ if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) {
+ return 'invalid_dimensions';
+ }
+ if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) {
+ return 'dimensions_out_of_range';
+ }
+ return 'accepted';
+}
+
+function apsValidCreativeUrl(value, publisherOrigin) {
+ try {
+ var url = new URL(value);
+ return url.protocol === 'https:' && url.hostname !== '' && url.username === '' &&
+ url.password === '' && url.origin !== publisherOrigin;
+ } catch (_error) {
+ return false;
+ }
+}
+
+function apsDecodeEnvelope(value) {
+ if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES ||
+ value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) {
+ return undefined;
+ }
+ try {
+ var binary = atob(value);
+ if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined;
+ var bytes = new Uint8Array(binary.length);
+ for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
+ } catch (_error) {
+ return undefined;
+ }
+}
+
+function classifyApsRendererDescriptorV1(
+ value
+) {
+ var renderer = value;
+ if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) &&
+ !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid';
+ if (renderer.type !== 'aps' || renderer.version !== 1 ||
+ typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 ||
+ apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES ||
+ typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 ||
+ apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES ||
+ apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid';
+ if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') &&
+ (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 ||
+ apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid';
+ if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid';
+
+ var widthResult = apsDimensionResult(renderer.width);
+ if (widthResult !== 'accepted') return widthResult;
+ var heightResult = apsDimensionResult(renderer.height);
+ if (heightResult !== 'accepted') return heightResult;
+
+ if (typeof renderer.creativeUrl !== 'string' ||
+ apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES ||
+ typeof renderer.aaxResponse !== 'string' ||
+ renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid';
+ return 'accepted';
+}
+
+function classifyApsRendererV1(
+ value,
+ publisherOrigin
+) {
+ var renderer = value;
+ var descriptorResult =
+ classifyApsRendererDescriptorV1(renderer);
+ if (descriptorResult !== 'accepted') return descriptorResult;
+ if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid';
+
+ var decoded = apsDecodeEnvelope(renderer.aaxResponse);
+ if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) ||
+ decoded.seatbid.length !== 1) return 'descriptor_invalid';
+ var seat = decoded.seatbid[0];
+ if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) ||
+ seat.bid.length !== 1) return 'descriptor_invalid';
+ var bid = seat.bid[0];
+ if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) ||
+ !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid';
+
+ var bidWidthResult = apsDimensionResult(bid.w);
+ if (bidWidthResult !== 'accepted') return bidWidthResult;
+ var bidHeightResult = apsDimensionResult(bid.h);
+ if (bidHeightResult !== 'accepted') return bidHeightResult;
+ if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height ||
+ bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType ||
+ typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) {
+ return 'descriptor_invalid';
+ }
+ return 'accepted';
+}
diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs
index 0dfb5906f..94e84ac79 100644
--- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs
+++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs
@@ -422,6 +422,7 @@ pub fn register(
.with_proxy(integration.clone())
.with_attribute_rewriter(integration.clone())
.with_script_rewriter(integration)
+ .with_empty_browser_config_v1()?
.build(),
))
}
diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs
index 84158c27e..0c93c1a3c 100644
--- a/crates/trusted-server-core/src/integrations/gpt.rs
+++ b/crates/trusted-server-core/src/integrations/gpt.rs
@@ -390,11 +390,26 @@ pub fn register(
return Ok(None);
};
+ #[derive(Serialize)]
+ #[serde(rename_all = "camelCase")]
+ struct GptBrowserConfigV1 {
+ gam_attribution_enabled: bool,
+ page_bids_enabled: bool,
+ }
+ let browser_config = GptBrowserConfigV1 {
+ gam_attribution_enabled: integration.config.gam_attribution_enabled,
+ page_bids_enabled: settings
+ .creative_opportunities
+ .as_ref()
+ .is_some_and(|config| config.enabled),
+ };
+
Ok(Some(
IntegrationRegistration::builder(GPT_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration.clone())
- .with_head_injector(integration)
+ .with_head_injector(integration.clone())
+ .with_browser_config_v1(&browser_config)?
.build(),
))
}
@@ -473,72 +488,12 @@ impl IntegrationHeadInjector for GptIntegration {
GPT_INTEGRATION_ID
}
- /// Injects the `tsjs.adInit` bootstrap script into ``.
- ///
- /// ## Scroll / refresh handoff contract (Phase 1)
- ///
- /// `tsjs.adInit` handles **initial render only**: it wires server-side bid
- /// targeting into GPT slots and refreshes them. Win/billing beacons fire
- /// only from the TS render bridge in the JS bundle, where a matching
- /// Prebid Universal Creative request proves the TS creative rendered.
- /// It does **not** trigger refresh auctions or handle GPT slot refresh events.
- ///
- /// Post-`window.load`, slim-Prebid owns scroll and GPT refresh: it listens
- /// for GPT refresh events, runs client-side auctions, and sets targeting for
- /// subsequent impressions. SPA navigation is handled separately by
- /// `installSpaAuctionHook()` in the GPT bundle, which re-runs the server-side
- /// auction via `GET /_ts/page-bids` on pushState / replaceState / popstate
- /// route changes (see `auction/endpoints.rs`).
- /// The `POST /auction` endpoint is not involved in scroll or refresh flows.
+ /// GPT startup is owned entirely by the release-matched TS integration.
fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec {
- let gam_attribution_flag = if self.config.gam_attribution_enabled {
- "window.__tsjs_gam_attribution_enabled=true;"
- } else {
- ""
- };
-
- let mut scripts = vec![
- format!(
- ""
- ),
- format!("", GPT_BOOTSTRAP_JS),
- ];
-
- if let Some(ref url) = self.config.slim_prebid_url {
- // JSON-encode the URL, then escape `` so a configured value
- // containing the literal `` cannot close this inline tag and
- // let trailing markup execute (standard JSON-in-HTML mitigation).
- let encoded = serde_json::to_string(url)
- .expect("should serialize string")
- .replace("", "<\\/");
- scripts.push(format!(
- ""
- ));
- }
-
- scripts
- }
-
- fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> {
- if self.config.gam_attribution_enabled {
- vec![("data-ts-gam-attribution", "true")]
- } else {
- Vec::new()
- }
+ Vec::new()
}
}
-/// Inline `window.tsjs.adInit` bootstrap injected at `` so the bids
-/// script at `` can call it before the TSJS bundle has loaded.
-///
-/// The bundle's idempotent implementation in
-/// `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` later overwrites this stub.
-/// Both implementations guard the one-time-per-page setup with
-/// `window.tsjs.servicesEnabled` so neither double-enables services if the
-/// publisher's own init code also calls `googletag.enableServices()`.
-const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js");
-
// Default value functions
fn default_enabled() -> bool {
@@ -616,6 +571,36 @@ mod tests {
assert!(enabled.gam_attribution_enabled);
}
+ #[test]
+ fn browser_config_projects_creative_opportunity_delivery_state() {
+ for enabled in [false, true] {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config("gpt", &serde_json::json!({ "enabled": true }))
+ .expect("should enable GPT");
+ settings.creative_opportunities = Some(
+ toml::from_str(&format!(
+ "enabled = {enabled}\ngam_network_id = \"12345\"\n"
+ ))
+ .expect("should build creative opportunity config"),
+ );
+
+ let registration = register(&settings)
+ .expect("GPT registration should succeed")
+ .expect("GPT should be enabled");
+
+ assert_eq!(
+ registration.browser_config_v1,
+ Some(serde_json::json!({
+ "gamAttributionEnabled": false,
+ "pageBidsEnabled": enabled,
+ })),
+ "GPT browser config must carry the exact publisher delivery switch"
+ );
+ }
+ }
+
// -- URL detection --
#[test]
@@ -1171,7 +1156,7 @@ mod tests {
// -- Head injector --
#[test]
- fn head_injector_emits_enable_flag() {
+ fn head_injector_is_empty_because_the_ts_module_owns_gpt_startup() {
let integration = GptIntegration::new(test_config());
let doc_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
@@ -1183,277 +1168,7 @@ mod tests {
let inserts = integration.head_inserts(&ctx);
- assert_eq!(inserts.len(), 2, "should emit exactly two head inserts");
- assert_eq!(
- inserts[0],
- "",
- "should set the enable flag and call the GPT shim activation function"
- );
- assert!(
- integration.tsjs_script_tag_attributes().is_empty(),
- "should not authorize GAM attribution metadata by default"
- );
- }
-
- #[test]
- fn gam_attribution_true_adds_both_activation_signals_without_a_new_insert() {
- let integration = GptIntegration::new(GptConfig {
- gam_attribution_enabled: true,
- ..test_config()
- });
- let document_state = IntegrationDocumentState::default();
- let context = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "origin.example.com",
- document_state: &document_state,
- };
-
- let inserts = integration.head_inserts(&context);
-
- assert_eq!(inserts.len(), 2, "should not add another head insert");
- assert!(
- inserts[0].contains("window.__tsjs_gam_attribution_enabled=true;"),
- "should activate the early bootstrap marker"
- );
- assert_eq!(
- integration.tsjs_script_tag_attributes(),
- vec![("data-ts-gam-attribution", "true")],
- "should authorize the bundle fallback on the publisher tag"
- );
- }
-
- #[test]
- fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() {
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let inserts = integration.head_inserts(&ctx);
- let combined = inserts.join("");
- assert!(combined.contains("ts.adInit"), "should define tsjs.adInit");
- assert!(
- combined.contains("ts.bids"),
- "should read tsjs.bids synchronously"
- );
- assert!(
- combined.contains("ts_initial"),
- "should set ts_initial sentinel"
- );
- assert!(
- !combined.contains("addEventListener(\"slotRenderEnded\""),
- "inline bootstrap cannot prove TS creative rendering from GPT slotRenderEnded"
- );
- assert!(
- !combined.contains("sendBeacon"),
- "inline bootstrap must not fire win/billing beacons from GPT slotRenderEnded"
- );
- assert!(
- !combined.contains("getTargeting(\"hb_adid\")"),
- "inline bootstrap must not treat GPT targeting as winner proof"
- );
- assert!(
- !combined.contains("/ts-bids"),
- "must NOT fetch /ts-bids — bids are inline on the page"
- );
- assert!(
- !combined.contains("bidsPromise"),
- "must NOT use bidsPromise — bids are synchronous"
- );
- assert!(
- !combined.contains("__ts_request_id"),
- "must NOT reference request_id — no longer used"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_installs_fallback_scheduler() {
- // The `` bids script hands its payload to
- // `tsjs.scheduleInitialAdInit`. The bundle installs the real scheduler,
- // but when the bundle fails to load this head bootstrap must provide
- // the degradation path — otherwise a failed bundle request would leave
- // initial server-side ads uninitialized. Executable coverage of the
- // fallback lives in the Vitest suite (gpt_bootstrap.test.ts).
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("ts.scheduleInitialAdInit"),
- "should install the fallback scheduler for bundle-load failures"
- );
- assert!(
- combined.contains("navGeneration"),
- "fallback scheduler should honor the navigation-generation guard"
- );
- assert!(
- combined.contains("requestAnimationFrame"),
- "fallback scheduler should defer past hydration frames"
- );
- assert!(
- combined.contains("\"load\""),
- "fallback scheduler should gate on window load"
- );
- // The no-retry-timer property is owned by the executable suite
- // (gpt_bootstrap.test.ts asserts adInit runs exactly once); a
- // `!contains("setTimeout")` over the whole joined head-insert output
- // would misattribute any future unrelated timer to the scheduler.
- }
-
- #[test]
- fn head_inserts_bootstrap_uses_css_safe_div_prefix_lookup() {
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("querySelectorAll(\"[id]\")"),
- "bootstrap should scan ID-bearing elements instead of interpolating div_id into CSS"
- );
- assert!(
- combined.contains("candidate.id.startsWith(divId)"),
- "bootstrap should match metacharacter-containing div_id prefixes with startsWith"
- );
- assert!(
- !combined.contains("[id^='\" + slot.div_id"),
- "bootstrap must not build a CSS attribute selector from raw div_id"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_installs_inner_div_slot_handoff() {
- let integration = GptIntegration::new(test_config());
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("gptSlotHandoffs"),
- "bootstrap should keep late publisher slot handoff state on window.tsjs"
- );
- assert!(
- combined.contains("__tsSlotHandoffPatched"),
- "bootstrap should install idempotent GPT handoff wrappers"
- );
- assert!(
- combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"),
- "bootstrap should define the TS fallback on the actual inner div"
- );
- assert!(
- !combined.contains("actualDivId + \"-container\""),
- "bootstrap must not define a competing outer-container GPT slot"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() {
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("ts.servicesEnabled"),
- "should guard enableServices/enableSingleRequest with the tsjs.servicesEnabled flag"
- );
- assert!(combined.contains("ts.adInit"), "should install tsjs.adInit");
- assert!(
- !combined.contains("googletag.pubads().refresh()"),
- "should never call unbounded refresh() — only refresh(newSlots)"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_refreshes_ts_slots_when_initial_load_disabled() {
- // Mirrors the bundle: when the publisher disables initial load through
- // setConfig() or the legacy disableInitialLoad() method, display() only
- // registers a TS-defined slot, so the bootstrap must also refresh those
- // slots or they render blank.
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("gpt.setConfig"),
- "bootstrap should wrap googletag.setConfig() to detect the disabled state"
- );
- assert!(
- combined.contains("gpt.getConfig"),
- "bootstrap should read GPT's modern initial-load configuration"
- );
- assert!(
- combined.contains("pubads.disableInitialLoad"),
- "bootstrap should wrap legacy disableInitialLoad() calls"
- );
- assert!(
- combined.contains("gptInitialLoadDisabled"),
- "bootstrap should record the initial-load-disabled state on window.tsjs"
- );
- assert!(
- combined.contains("slotsNeedingRefresh"),
- "bootstrap should refresh TS-defined slots when initial load is disabled"
- );
- }
-
- #[test]
- fn head_inserts_queue_gam_attribution_before_guard_and_ad_requests() {
- let targeting_index = GPT_BOOTSTRAP_JS
- .find("gpt.setConfig({ targeting: { ts: \"true\" } })")
- .expect("should apply the fixed page-level GAM targeting pair");
- let guard_index = GPT_BOOTSTRAP_JS
- .find("if (ts.adInit) return;")
- .expect("should retain the preinstalled adInit guard");
- let display_index = GPT_BOOTSTRAP_JS
- .find("googletag.display(divId);")
- .expect("should retain the executable GPT display call");
- let refresh_index = GPT_BOOTSTRAP_JS
- .find("googletag.pubads().refresh(slotsNeedingRefresh);")
- .expect("should retain the bounded GPT refresh call");
-
- assert!(
- targeting_index < guard_index,
- "should enqueue attribution before the preinstalled adInit guard"
- );
- assert!(
- targeting_index < display_index,
- "should enqueue attribution before the executable display call"
- );
- assert!(
- targeting_index < refresh_index,
- "should enqueue attribution before the executable refresh call"
- );
+ assert!(inserts.is_empty(), "must not retain a second GPT bootstrap");
}
#[test]
@@ -1464,97 +1179,4 @@ mod tests {
"gpt"
);
}
-
- #[test]
- fn head_inserts_emits_slim_prebid_url_when_configured() {
- let config = GptConfig {
- slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()),
- ..test_config()
- };
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
-
- assert_eq!(
- inserts.len(),
- 3,
- "should emit three head inserts when slim_prebid_url is set"
- );
- assert_eq!(
- inserts[2],
- r#""#,
- "should emit the slim-Prebid URL as a JSON-encoded string assignment"
- );
- }
-
- #[test]
- fn head_inserts_escapes_script_terminator_in_slim_prebid_url() {
- // A configured URL containing `` must not close the inline tag.
- let config = GptConfig {
- slim_prebid_url: Some("https://cdn.example.com/x ".to_string()),
- ..test_config()
- };
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
-
- // The injected ` ` must be neutralised: the only
- // `` left is the tag's own legitimate closer.
- assert!(
- !inserts[2].contains(" terminator, got: {}",
- inserts[2]
- );
- assert_eq!(
- inserts[2].matches("").count(),
- 1,
- "only the tag's own closing should remain, got: {}",
- inserts[2]
- );
- assert!(
- inserts[2].contains("<\\/script>"),
- "should emit the escaped terminator, got: {}",
- inserts[2]
- );
- }
-
- #[test]
- fn head_inserts_omits_slim_prebid_url_when_not_configured() {
- let integration = GptIntegration::new(test_config());
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
-
- assert_eq!(
- inserts.len(),
- 2,
- "should emit exactly two head inserts when slim_prebid_url is absent"
- );
- assert!(
- inserts
- .iter()
- .all(|s| !s.contains("__tsjs_slim_prebid_url")),
- "should not emit slim-Prebid URL tag when not configured"
- );
- }
}
diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js
deleted file mode 100644
index 2475c5082..000000000
--- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js
+++ /dev/null
@@ -1,585 +0,0 @@
-// Edge-injected GPT auction bootstrap.
-//
-// This is the minimal `window.tsjs.adInit` that runs on first page load
-// before the TSJS bundle has had a chance to install its richer
-// idempotent implementation. The bundle in
-// crates/trusted-server-js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit`
-// once it loads.
-//
-// Contract with the bundle:
-// - Both implementations must set `window.tsjs.servicesEnabled = true`
-// after calling `enableSingleRequest()`/`enableServices()` so a
-// subsequent call becomes a no-op.
-// - `refresh()` is called only for the slots defined in this pass,
-// never the global slot list.
-//
-// Only installed if `window.tsjs.adInit` isn't already defined.
-(function () {
- if (typeof window === "undefined") return;
- var ts = (window.tsjs = window.tsjs || {});
- var tag;
-
- if (window.__tsjs_gam_attribution_enabled === true) {
- tag = window.googletag = window.googletag || { cmd: [] };
- tag.cmd = tag.cmd || [];
- tag.cmd.push(function () {
- try {
- var gpt = window.googletag;
- if (gpt && typeof gpt.setConfig === "function") {
- // "ts" is the fixed GAM key, not the local window.tsjs alias.
- gpt.setConfig({ targeting: { ts: "true" } });
- }
- } catch (error) {
- // Attribution must not interrupt the existing bootstrap queue.
- ts.log &&
- ts.log.warn &&
- ts.log.warn("GAM attribution targeting failed", error);
- }
- });
- }
-
- if (ts.adInit) return;
-
- // Track whether the publisher disabled GPT initial load. Read the effective
- // googletag.getConfig() value when available, and wrap googletag.setConfig()
- // and the legacy pubads().disableInitialLoad() method so changes are
- // synchronized immediately and still detected when getConfig() is
- // unavailable. With initial load disabled, display() only registers a slot
- // and the ad request must come from a later refresh(); adInit() reads this to
- // refresh its own freshly defined
- // slots so they are not left blank. Pushed onto the command queue so it runs
- // before the publisher's own GPT configuration.
- function syncInitialLoadDisabled(gpt) {
- if (typeof gpt.getConfig !== "function") return false;
- var config = gpt.getConfig("disableInitialLoad");
- if (!config || typeof config.disableInitialLoad === "undefined") {
- return false;
- }
- ts.gptInitialLoadDisabled = config.disableInitialLoad === true;
- return true;
- }
-
- tag = tag || (window.googletag = window.googletag || { cmd: [] });
- tag.cmd = tag.cmd || [];
- tag.cmd.push(function () {
- var gpt = window.googletag;
- syncInitialLoadDisabled(gpt);
- if (
- typeof gpt.setConfig === "function" &&
- !gpt.__tsInitialLoadConfigHooked
- ) {
- var originalSetConfig = gpt.setConfig.bind(gpt);
- gpt.setConfig = function (config) {
- var result = originalSetConfig.apply(gpt, arguments);
- if (
- !syncInitialLoadDisabled(gpt) &&
- config &&
- "disableInitialLoad" in config
- ) {
- ts.gptInitialLoadDisabled = config.disableInitialLoad === true;
- }
- return result;
- };
- gpt.__tsInitialLoadConfigHooked = true;
- }
-
- var pubads = gpt.pubads && gpt.pubads();
- if (
- !pubads ||
- typeof pubads.disableInitialLoad !== "function" ||
- pubads.__tsInitialLoadHooked
- ) {
- return;
- }
- var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads);
- pubads.disableInitialLoad = function () {
- var result = originalDisableInitialLoad.apply(pubads, arguments);
- if (!syncInitialLoadDisabled(gpt)) {
- ts.gptInitialLoadDisabled = true;
- }
- return result;
- };
- pubads.__tsInitialLoadHooked = true;
- });
-
- // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's
- // hydration-safe scheduler in
- // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the
- // bids script hands the SSR bids payload to this scheduler, which applies
- // it and runs adInit only while the page is still on navigation
- // generation 0 (the SSR document), after window load plus a double
- // requestAnimationFrame so the call lands outside React's hydration
- // window. Keeps initial server-side ads working when the main TSJS bundle
- // fails to load; the bundle overwrites this with the full implementation.
- //
- // Hidden documents: rAF is not serviced while the document is hidden, so a
- // background-tab load holds the initial adInit until first view. Intended,
- // and deliberately identical to the bundle scheduler — the impression is
- // spent on a viewed tab, and the post-hydration guarantee holds whenever
- // the request is actually issued.
- ts.scheduleInitialAdInit = function (initialBids, initialSlots) {
- // The bundle may replace this scheduler after the fallback claims the initial
- // pass. Keep the latch on the shared document API so replacement cannot reset it.
- if ((ts.navGeneration || 0) !== 0 || ts.initialAdInitScheduled) return;
- ts.initialAdInitScheduled = true;
- // Slots are generation-guarded for the same reason the bids are: the
- // shared-template seam sends both, and an assignment made before this call
- // would overwrite a committed SPA navigation's slots.
- if (initialSlots !== undefined) ts.adSlots = initialSlots;
- if (initialBids !== undefined) ts.bids = initialBids;
- var fire = function () {
- if ((ts.navGeneration || 0) !== 0) return;
- if (typeof ts.adInit === "function") ts.adInit();
- };
- var afterFrames = function () {
- window.requestAnimationFrame(function () {
- window.requestAnimationFrame(fire);
- });
- };
- if (document.readyState === "complete") afterFrames();
- else window.addEventListener("load", afterFrames, { once: true });
- };
-
- function findSlotByElementId(pubads, elementId) {
- var slots = pubads.getSlots ? pubads.getSlots() : [];
- return (
- slots.find(function (slot) {
- return slot.getSlotElementId() === elementId;
- }) || null
- );
- }
-
- function normalizedGptFormats(formats) {
- return formats.length === 2 &&
- formats.every(function (format) {
- return typeof format === "number";
- })
- ? [formats]
- : formats;
- }
-
- function handoffFormatsMatch(handoff, formats) {
- return (
- JSON.stringify(handoff.formats) ===
- JSON.stringify(normalizedGptFormats(formats))
- );
- }
-
- function matchingHandoff(pubads, adUnitPath, formats, elementId) {
- var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId];
- if (exact) return exact.publisherClaimed ? null : exact;
-
- var candidates = Object.values(ts.gptSlotHandoffs || {}).filter(
- function (handoff, index, allHandoffs) {
- return (
- allHandoffs.indexOf(handoff) === index &&
- !handoff.publisherClaimed &&
- !document.getElementById(handoff.slotElementId) &&
- elementId.startsWith(handoff.divIdPrefix) &&
- handoff.gamUnitPath === adUnitPath &&
- handoffFormatsMatch(handoff, formats) &&
- findSlotByElementId(pubads, handoff.slotElementId)
- );
- },
- );
- return candidates.length === 1 ? candidates[0] : null;
- }
-
- function displayTargetElementId(target) {
- if (typeof target === "string") return target;
- if (target && typeof target.getSlotElementId === "function") {
- return target.getSlotElementId();
- }
- return target && target.id ? target.id : null;
- }
-
- function isElementVisible(element) {
- if (typeof element.checkVisibility === "function") {
- return element.checkVisibility({
- checkVisibilityCSS: true,
- visibilityProperty: true,
- });
- }
-
- for (var current = element; current; current = current.parentElement) {
- var style = window.getComputedStyle(current);
- if (
- style.display === "none" ||
- style.visibility === "hidden" ||
- style.visibility === "collapse"
- ) {
- return false;
- }
- }
- return true;
- }
-
- function slotElementHasLayout(element) {
- if (!isElementVisible(element)) return false;
- var elementRect = element.getBoundingClientRect();
- if (elementRect.width > 0 && elementRect.height > 0) return true;
-
- var container = document.getElementById(element.id + "-container");
- if (!container || !isElementVisible(container)) return false;
- var containerRect = container.getBoundingClientRect();
- return containerRect.width > 0;
- }
-
- function resolveSlotElementByDivId(divId) {
- if (!divId) {
- return { element: null, prefixMatchCount: 0, activeMatchCount: 0 };
- }
- // Exact-id matches intentionally skip the visibility tiers below: a
- // configured literal id is unambiguous, so a hidden match is still the
- // right element. Prefix matches go through the tiers because a prefix can
- // match several candidates and only visibility/layout disambiguates them —
- // so a hidden exact-id match resolves while a hidden prefix match does not.
- var exact = document.getElementById(divId);
- if (exact) {
- return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 };
- }
-
- var idElements = document.querySelectorAll("[id]");
- var prefixMatches = [];
- for (var i = 0; i < idElements.length; i++) {
- var candidate = idElements[i];
- if (
- candidate.id.startsWith(divId) &&
- !candidate.id.endsWith("-container")
- ) {
- prefixMatches.push(candidate);
- }
- }
- // A unique prefix match may be a lazy slot that has not been sized yet,
- // but it must still be visible through its ancestor containers.
- if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0])) {
- return {
- element: prefixMatches[0],
- prefixMatchCount: 1,
- activeMatchCount: 1,
- };
- }
-
- var visibleMatches = prefixMatches.filter(isElementVisible);
- if (visibleMatches.length === 1) {
- return {
- element: visibleMatches[0],
- prefixMatchCount: prefixMatches.length,
- activeMatchCount: 1,
- };
- }
-
- var activeMatches = visibleMatches.filter(slotElementHasLayout);
- return {
- element: activeMatches.length === 1 ? activeMatches[0] : null,
- prefixMatchCount: prefixMatches.length,
- activeMatchCount: activeMatches.length,
- };
- }
-
- function runHandoffInternal(callback) {
- var wasInternal = ts.gptSlotHandoffInternal;
- ts.gptSlotHandoffInternal = true;
- try {
- return callback();
- } finally {
- ts.gptSlotHandoffInternal = wasInternal;
- }
- }
-
- // TS cannot wait an arbitrary amount of time for a framework to define a
- // slot: publishers that never define one would render blank. Instead, TS
- // defines its fallback on the actual inner div and aliases only a later
- // publisher defineSlot() for that exact div, or a hydration-renamed replacement
- // after the original div is gone, to the same GPT slot.
- function installSlotHandoff() {
- window.googletag.cmd.push(function () {
- var tag = window.googletag;
- var pubads = tag.pubads && tag.pubads();
- if (!tag.defineSlot || !tag.display || !pubads) return;
-
- if (!tag.defineSlot.__tsSlotHandoffPatched) {
- var originalDefineSlot = tag.defineSlot.bind(tag);
- var patchedDefineSlot = function (adUnitPath, formats, elementId) {
- if (!ts.gptSlotHandoffInternal && typeof elementId === "string") {
- var handoff = matchingHandoff(
- pubads,
- adUnitPath,
- formats,
- elementId,
- );
- if (handoff) {
- var existingSlot = findSlotByElementId(
- pubads,
- handoff.slotElementId,
- );
- if (existingSlot) {
- ts.gptSlotHandoffs[elementId] = handoff;
- handoff.publisherClaimed = true;
- // The supported publisher lifecycle is defineSlot → addService → display.
- // Intentionally wait for that display instead of applying a time heuristic.
- handoff.suppressPublisherDisplay = true;
- handoff.suppressPublisherRefresh =
- ts.gptInitialLoadDisabled === true;
- ts.prevGptSlots = (ts.prevGptSlots || []).filter(
- function (ownedSlot) {
- return ownedSlot !== existingSlot;
- },
- );
- if (
- handoff.gamUnitPath !== adUnitPath ||
- !handoffFormatsMatch(handoff, formats)
- ) {
- ts.log &&
- ts.log.warn &&
- ts.log.warn(
- "GPT slot handoff: publisher definition differs from TS configuration",
- elementId,
- );
- }
- return existingSlot;
- }
- }
- }
- return elementId === undefined
- ? originalDefineSlot(adUnitPath, formats)
- : originalDefineSlot(adUnitPath, formats, elementId);
- };
- patchedDefineSlot.__tsSlotHandoffPatched = true;
- tag.defineSlot = patchedDefineSlot;
- }
-
- if (!tag.display.__tsSlotHandoffPatched) {
- var originalDisplay = tag.display.bind(tag);
- var patchedDisplay = function (target) {
- var elementId = displayTargetElementId(target);
- var handoff =
- elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId];
- if (
- !ts.gptSlotHandoffInternal &&
- handoff &&
- handoff.suppressPublisherDisplay
- ) {
- handoff.suppressPublisherDisplay = false;
- return;
- }
- originalDisplay(target);
- };
- patchedDisplay.__tsSlotHandoffPatched = true;
- tag.display = patchedDisplay;
- }
-
- if (!pubads.refresh.__tsSlotHandoffPatched) {
- var originalRefresh = pubads.refresh.bind(pubads);
- var callRefresh = function (slots, options) {
- if (options === undefined) {
- originalRefresh(slots);
- } else {
- originalRefresh(slots, options);
- }
- };
- var patchedRefresh = function (requestedSlots, options) {
- if (ts.gptSlotHandoffInternal) {
- callRefresh(requestedSlots, options);
- return;
- }
- var slots =
- requestedSlots || (pubads.getSlots ? pubads.getSlots() : null);
- if (!slots) {
- callRefresh(requestedSlots, options);
- return;
- }
- var suppressed = false;
- var remainingSlots = slots.filter(function (slot) {
- var handoff =
- ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()];
- if (!handoff || !handoff.suppressPublisherRefresh) return true;
- handoff.suppressPublisherRefresh = false;
- suppressed = true;
- return false;
- });
- if (!suppressed) {
- callRefresh(requestedSlots, options);
- } else if (remainingSlots.length > 0) {
- callRefresh(remainingSlots, options);
- }
- };
- patchedRefresh.__tsSlotHandoffPatched = true;
- pubads.refresh = patchedRefresh;
- }
- });
- }
-
- installSlotHandoff();
-
- ts.adInit = function () {
- var slots = ts.adSlots || [];
- var bids = ts.bids || {};
- var divToSlotId = {};
- // Generation this invocation belongs to. The slot work below is queued on
- // googletag.cmd, which drains only when GPT loads; recheck first inside
- // the queued callback so a navigation committed in the gap cancels the
- // stale mutation — mirrors the bundle's adInit.
- var generation = ts.navGeneration || 0;
- var warnedResolutionFailures = Object.create(null);
-
- googletag.cmd.push(function () {
- if ((ts.navGeneration || 0) !== generation) return;
- // Slots TS defined itself — tracked for SPA destroy. Publisher-owned
- // slots are reused but never destroyed by TS on navigation.
- var newSlots = [];
- // Publisher-owned slots TS reused — refreshed to pick up server-side
- // targeting. The publisher already display()ed these.
- var slotsToRefresh = [];
- // Element IDs of slots TS defined itself. GPT requires display() to
- // register/render a freshly-defined slot; refresh() alone no-ops for a
- // slot that was never displayed, so these are display()ed instead.
- var slotsToDisplay = [];
- slots.forEach(function (slot) {
- // Resolve actual div ID: exact match first, then the visibility and
- // geometry tiers for prefix matches. Responsive publishers may emit
- // several mutually exclusive siblings for one stable prefix, so
- // document order is not sufficient.
- var resolution = resolveSlotElementByDivId(slot.div_id);
- var el = resolution.element;
- if (!el) {
- if (!warnedResolutionFailures[slot.div_id]) {
- if (resolution.prefixMatchCount > 1) {
- warnedResolutionFailures[slot.div_id] = true;
- if (ts.log && typeof ts.log.warn === "function") {
- ts.log.warn(
- "GPT slot prefix did not resolve to one active element",
- {
- divId: slot.div_id,
- prefixMatchCount: resolution.prefixMatchCount,
- activeMatchCount: resolution.activeMatchCount,
- },
- );
- }
- } else if (
- resolution.prefixMatchCount === 1 &&
- resolution.activeMatchCount === 0
- ) {
- // The common breakpoint-hidden config: the prefix matched one
- // element but it is hidden, so the slot is skipped. Logged so a
- // blank placement is diagnosable without stepping the resolver.
- warnedResolutionFailures[slot.div_id] = true;
- if (ts.log && typeof ts.log.debug === "function") {
- ts.log.debug(
- "GPT slot prefix matched only a hidden element; skipping slot",
- { divId: slot.div_id },
- );
- }
- }
- }
- return;
- }
- var actualDivId = el.id;
- var b = bids[slot.id] || {};
-
- var existingSlots = googletag.pubads().getSlots();
- var s =
- existingSlots.find(function (gs) {
- return gs.getSlotElementId() === actualDivId;
- }) || null;
- var tsOwned = false;
- if (!s) {
- // Define TS's fallback on the publisher's actual div. The scoped
- // handoff wrapper returns this slot if the publisher defines it later.
- s = runHandoffInternal(function () {
- return googletag.defineSlot(
- slot.gam_unit_path,
- slot.formats,
- actualDivId,
- );
- });
- if (!s) return;
- s.addService(googletag.pubads());
- tsOwned = true;
- ts.gptSlotHandoffs = ts.gptSlotHandoffs || {};
- ts.gptSlotHandoffs[actualDivId] = {
- gamUnitPath: slot.gam_unit_path,
- formats: slot.formats,
- divIdPrefix: slot.div_id,
- slotElementId: actualDivId,
- publisherClaimed: false,
- suppressPublisherDisplay: false,
- suppressPublisherRefresh: false,
- };
- }
-
- Object.entries(slot.targeting || {}).forEach(function (e) {
- s.setTargeting(e[0], e[1]);
- });
- [
- "hb_pb",
- "hb_bidder",
- "hb_adid",
- "hb_cache_host",
- "hb_cache_path",
- ].forEach(function (k) {
- if (b[k]) s.setTargeting(k, b[k]);
- });
- // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts
- s.setTargeting("ts_initial", "1");
- // Map the resolved inner div to the slot ID. This bootstrap fires no
- // beacons and registers no slotRenderEnded listener; the map is consumed
- // by the bundle's render bridge (index.ts) once it loads.
- divToSlotId[actualDivId] = slot.id;
- var slotElementId = s.getSlotElementId();
- if (slotElementId && slotElementId !== actualDivId) {
- divToSlotId[slotElementId] = slot.id;
- }
- if (tsOwned) {
- newSlots.push(s);
- var displayId = s.getSlotElementId() || actualDivId;
- slotsToDisplay.push(displayId);
- } else {
- slotsToRefresh.push(s);
- }
- });
- ts.prevGptSlots = newSlots;
- ts.divToSlotId = divToSlotId;
- if (!ts.servicesEnabled) {
- googletag.pubads().enableSingleRequest();
- googletag.enableServices();
- ts.servicesEnabled = true;
- }
- // Register and render TS-defined slots. GPT requires display() for a
- // freshly-defined slot; without it the slot no-ops and misses its
- // impression. Runs after enableServices(); on SPA navigation services are
- // already enabled, so this runs unconditionally for new slots.
- slotsToDisplay.forEach(function (divId) {
- runHandoffInternal(function () {
- googletag.display(divId);
- });
- });
- syncInitialLoadDisabled(window.googletag);
-
- // Reused publisher-owned slots always need a refresh to pick up the
- // server-side targeting. TS-defined slots are fetched by display() above
- // unless the publisher disabled initial load, in which case display() only
- // registers them and refresh() must request the ad — otherwise they render
- // blank. Only add them in that case to avoid double-requesting.
- var slotsNeedingRefresh = ts.gptInitialLoadDisabled
- ? slotsToRefresh.concat(newSlots)
- : slotsToRefresh;
-
- if (slotsNeedingRefresh.length > 0) {
- // One-shot bypass: this internal refresh delivers the just-applied
- // server-side targeting to GAM. If slim-Prebid has already wrapped
- // refresh(), it must pass this call straight through — not clear the
- // targeting and run a duplicate client-side auction. Mirrors the
- // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts.
- ts.adInitRefreshInProgress = true;
- try {
- runHandoffInternal(function () {
- googletag.pubads().refresh(slotsNeedingRefresh);
- });
- } finally {
- ts.adInitRefreshInProgress = false;
- }
- }
- });
- };
-})();
diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs
index 94e4607cd..f3c7f2c58 100644
--- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs
+++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs
@@ -1,9 +1,9 @@
//! Query-activated, browser-session GPT runtime diagnostics integration.
//!
-//! Deployment configuration makes the standalone browser module available.
-//! Exact `ts_console` directives establish or clear a host-only session cookie;
-//! active documents load the module synchronously without adding diagnostics to
-//! the ordinary unified bundle.
+//! Exact `ts_console` directives establish or clear a host-only session cookie.
+//! Active documents select `gpt_diagnostics` as a takeover member of the unified
+//! TSJS manifest, whose runtime owns diagnostics fact capture and delivery. No
+//! standalone diagnostics browser module or script tag is emitted.
use error_stack::{Report, ResultExt};
use http::{HeaderValue, Method, Request, Response, Uri, header, uri::PathAndQuery};
@@ -16,7 +16,6 @@ use crate::error::TrustedServerError;
use crate::http_util::is_navigation_request;
use crate::response_privacy::enforce_synthesized_html_cache_privacy;
use crate::settings::{IntegrationConfig, Settings};
-use crate::tsjs;
use super::IntegrationRegistration;
@@ -26,7 +25,6 @@ pub const GPT_DIAGNOSTICS_INTEGRATION_ID: &str = "gpt_diagnostics";
pub const GPT_DIAGNOSTICS_QUERY: &str = "ts_console";
/// Host-only browser-session activation cookie.
pub const GPT_DIAGNOSTICS_COOKIE: &str = "__Host-ts-console";
-
const SET_CONSOLE_COOKIE: &str = "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax";
const CLEAR_CONSOLE_COOKIE: &str =
"__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0";
@@ -35,7 +33,7 @@ const CLEAR_CONSOLE_COOKIE: &str =
#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct GptDiagnosticsConfig {
- /// Whether the GPT diagnostics browser module is available.
+ /// Whether requests may activate the unified GPT diagnostics runtime.
#[serde(default)]
pub enabled: bool,
}
@@ -62,6 +60,7 @@ pub enum GptDiagnosticsCookieAction {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GptDiagnosticsRequestDecision {
active: bool,
+ reserved_directive: bool,
clean_browser_path_and_query: Option,
cookie_action: GptDiagnosticsCookieAction,
}
@@ -73,57 +72,32 @@ impl GptDiagnosticsRequestDecision {
self.active
}
+ /// Serialize the exact `DiagnosticsBootV1.gpt` value for the boot emitter.
+ #[must_use]
+ pub fn boot_config_json(&self) -> &'static str {
+ if self.active {
+ r#"{"active":true}"#
+ } else {
+ r#"{"active":false}"#
+ }
+ }
+
/// Whether the response must be private and non-storeable.
#[must_use]
pub fn requires_private_no_store(&self) -> bool {
self.active
|| self.cookie_action != GptDiagnosticsCookieAction::None
+ || self.reserved_directive
|| self.clean_browser_path_and_query.is_some()
}
- /// Build the early activation/URL-cleanup bootstrap for an HTML document.
+ /// Build the one-time inline URL-cleanup tag after reserved input was consumed.
#[must_use]
- pub fn bootstrap_script(&self) -> Option {
- if !self.active && self.clean_browser_path_and_query.is_none() {
- return None;
- }
-
- let mut script = String::from("");
- Some(script)
- }
-
- /// Build the synchronous standalone diagnostics module tag.
- #[must_use]
- pub fn module_script_tag(&self) -> Option {
- self.active.then(|| {
- format!(
- "",
- tsjs::tsjs_single_module_script_src(GPT_DIAGNOSTICS_INTEGRATION_ID)
- )
- })
- }
- /// An active decision, for tests in other modules that need one.
- ///
- /// The fields are private and built by `prepare_request` from a cookie or query
- /// parameter; there is no other way to obtain an active decision across a module
- /// boundary.
- #[cfg(test)]
- pub(crate) fn active_for_tests() -> Self {
- Self {
- active: true,
- clean_browser_path_and_query: None,
- cookie_action: GptDiagnosticsCookieAction::None,
- }
+ pub fn url_cleanup_script_tag(&self) -> Option {
+ let clean_path = serde_json::to_string(self.clean_browser_path_and_query.as_ref()?).ok()?;
+ Some(format!(
+ ""
+ ))
}
}
@@ -141,11 +115,14 @@ mod head_seam_invariant_tests {
GptDiagnosticsCookieAction::SetSession,
GptDiagnosticsCookieAction::ClearSession,
] {
- out.push(GptDiagnosticsRequestDecision {
- active,
- clean_browser_path_and_query: clean.clone(),
- cookie_action,
- });
+ for reserved_directive in [false, true] {
+ out.push(GptDiagnosticsRequestDecision {
+ active,
+ clean_browser_path_and_query: clean.clone(),
+ cookie_action,
+ reserved_directive,
+ });
+ }
}
}
}
@@ -163,8 +140,7 @@ mod head_seam_invariant_tests {
// If a future change makes a script emit without also requiring the stamp,
// this fails here rather than silently in a cached template.
for decision in all_decisions() {
- let injects =
- decision.bootstrap_script().is_some() || decision.module_script_tag().is_some();
+ let injects = decision.url_cleanup_script_tag().is_some();
if injects {
assert!(
decision.requires_private_no_store(),
@@ -178,16 +154,7 @@ mod head_seam_invariant_tests {
#[test]
fn a_default_decision_injects_nothing() {
let decision = GptDiagnosticsRequestDecision::default();
- assert_eq!(
- decision.bootstrap_script(),
- None,
- "should not inject a bootstrap for an inert decision"
- );
- assert_eq!(
- decision.module_script_tag(),
- None,
- "should not inject a module for an inert decision"
- );
+ assert_eq!(decision.url_cleanup_script_tag(), None);
assert!(
!decision.requires_private_no_store(),
"an inert decision should not force the response private"
@@ -279,9 +246,13 @@ pub fn prepare_request(
replace_path_and_query(request, &clean_path)?;
}
- let mut decision = GptDiagnosticsRequestDecision::default();
+ let mut decision = GptDiagnosticsRequestDecision {
+ reserved_directive: had_reserved_query,
+ clean_browser_path_and_query: (eligible_navigation && had_reserved_query)
+ .then_some(clean_path),
+ ..GptDiagnosticsRequestDecision::default()
+ };
if integration_enabled && eligible_navigation && had_reserved_query {
- decision.clean_browser_path_and_query = Some(clean_path);
match directive {
QueryDirective::Enable => {
decision.active = true;
@@ -320,6 +291,7 @@ pub fn finalize_response(
decision: &GptDiagnosticsRequestDecision,
response: &mut Response,
) {
+ sanitize_console_set_cookie(response);
let cookie = match decision.cookie_action {
GptDiagnosticsCookieAction::None => None,
GptDiagnosticsCookieAction::SetSession => {
@@ -379,9 +351,9 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState {
for cookie in value.split(';') {
let cookie = cookie.trim();
match cookie.split_once('=') {
- Some((name, value)) if name.trim() == GPT_DIAGNOSTICS_COOKIE => {
+ Some((name, value)) if name == GPT_DIAGNOSTICS_COOKIE => {
state.occurrences += 1;
- state.canonical |= value.trim() == "1";
+ state.canonical |= value == "1";
}
None if cookie == GPT_DIAGNOSTICS_COOKIE => state.occurrences += 1,
_ => {}
@@ -391,6 +363,33 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState {
state
}
+fn sanitize_console_set_cookie(response: &mut Response) {
+ let retained = response
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .filter(|value| {
+ let pair = value
+ .as_bytes()
+ .split(|byte| *byte == b';')
+ .next()
+ .unwrap_or_default();
+ let name = pair
+ .split(|byte| *byte == b'=')
+ .next()
+ .unwrap_or_default()
+ .trim_ascii();
+ name != GPT_DIAGNOSTICS_COOKIE.as_bytes()
+ })
+ .cloned()
+ .collect::>();
+
+ response.headers_mut().remove(header::SET_COOKIE);
+ for value in retained {
+ response.headers_mut().append(header::SET_COOKIE, value);
+ }
+}
+
fn sanitize_console_cookie(request: &mut Request) {
let retained = request
.headers()
@@ -466,7 +465,7 @@ mod tests {
}
#[test]
- fn register_excludes_diagnostics_from_unified_and_deferred_bundles() {
+ fn ts_console_register_defers_browser_delivery_to_the_unified_takeover_catalog() {
let registry = IntegrationRegistry::new(&settings(true)).expect("should build registry");
assert!(registry.integration_enabled(GPT_DIAGNOSTICS_INTEGRATION_ID));
@@ -483,7 +482,7 @@ mod tests {
}
#[test]
- fn exact_directive_activates_cleans_and_strips_cookie() {
+ fn ts_console_exact_directive_activates_cleans_and_strips_cookie() {
let mut request = navigation(
"https://publisher.example/page?keep=%2F&ts_console=true#fragment",
Some("other=value; __Host-ts-console=1"),
@@ -501,13 +500,18 @@ mod tests {
"https://publisher.example/page?keep=%2F"
);
assert_eq!(request.headers()[header::COOKIE], "other=value");
- let bootstrap = decision.bootstrap_script().expect("should bootstrap");
- assert!(bootstrap.contains("__tsjs_gpt_diagnostics_active=true"));
- assert!(bootstrap.contains("/page?keep=%2F"));
+ assert_eq!(decision.boot_config_json(), r#"{"active":true}"#);
+ let cleanup = decision
+ .url_cleanup_script_tag()
+ .expect("a server-consumed directive should authorize inline cleanup");
+ assert!(cleanup.starts_with(""));
+ assert!(cleanup.contains("history.replaceState"));
+ assert!(!cleanup.contains(" src="));
}
#[test]
- fn prefetch_directive_is_sanitized_without_activating_session() {
+ fn ts_console_prefetch_directive_is_sanitized_without_activating_session() {
let mut request = navigation("https://publisher.example/page?ts_console=1&keep=1", None);
request
.headers_mut()
@@ -524,13 +528,14 @@ mod tests {
}
#[test]
- fn active_cookie_enables_clean_navigation_but_duplicates_fail_closed() {
+ fn ts_console_active_cookie_enables_clean_navigation_but_duplicates_fail_closed() {
let mut active = navigation(
"https://publisher.example/page",
Some("__Host-ts-console=1; other=value"),
);
let decision = prepare_request(&settings(true), &mut active).expect("should prepare");
assert!(decision.active());
+ assert_eq!(decision.url_cleanup_script_tag(), None);
assert_eq!(active.headers()[header::COOKIE], "other=value");
let mut duplicate = navigation(
@@ -540,10 +545,17 @@ mod tests {
let decision = prepare_request(&settings(true), &mut duplicate).expect("should prepare");
assert!(!decision.active());
assert_eq!(duplicate.headers()[header::COOKIE], "other=value");
+
+ for noncanonical in ["__Host-ts-console =1", "__Host-ts-console= 1"] {
+ let mut request = navigation("https://publisher.example/page", Some(noncanonical));
+ let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
+ assert!(!decision.active(), "{noncanonical} must fail closed");
+ assert!(!request.headers().contains_key(header::COOKIE));
+ }
}
#[test]
- fn invalid_duplicate_and_disable_directives_fail_closed() {
+ fn ts_console_invalid_duplicate_and_disable_directives_fail_closed() {
for query in [
"ts_console=True",
"ts_console=",
@@ -572,7 +584,47 @@ mod tests {
}
#[test]
- fn finalization_sets_cookie_and_strips_shared_cache_headers() {
+ fn ts_console_invalid_directive_is_private_without_mutating_the_session() {
+ let mut request = navigation(
+ "https://publisher.example/page?keep=1&ts_console=True",
+ Some("__Host-ts-console=1"),
+ );
+ let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
+ let mut response = Response::builder()
+ .header(header::CACHE_CONTROL, "public, max-age=60")
+ .body(EdgeBody::empty())
+ .expect("should build response");
+
+ finalize_response(&decision, &mut response);
+
+ assert!(!decision.active());
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert!(!response.headers().contains_key(header::SET_COOKIE));
+ assert_eq!(
+ response.headers()[header::CACHE_CONTROL],
+ "private, no-store"
+ );
+ }
+
+ #[test]
+ fn ts_console_is_disabled_by_default_while_reserved_input_is_still_sanitized() {
+ let mut request = navigation(
+ "https://publisher.example/page?keep=1&ts_console=1",
+ Some("__Host-ts-console=1; other=value"),
+ );
+
+ let decision = prepare_request(&settings(false), &mut request).expect("should prepare");
+
+ assert!(!decision.active());
+ assert_eq!(decision.boot_config_json(), r#"{"active":false}"#);
+ assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None);
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert_eq!(request.headers()[header::COOKIE], "other=value");
+ assert!(decision.requires_private_no_store());
+ }
+
+ #[test]
+ fn ts_console_finalization_sets_cookie_and_strips_shared_cache_headers() {
let mut request = navigation("https://publisher.example/?ts_console=1", None);
let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
let mut response = Response::builder()
@@ -582,6 +634,11 @@ mod tests {
.header("surrogate-control", "max-age=60")
.header("fastly-surrogate-control", "max-age=60")
.header("cloudflare-cdn-cache-control", "public, max-age=60")
+ .header(header::SET_COOKIE, "publisher=value; Path=/")
+ .header(
+ header::SET_COOKIE,
+ "__Host-ts-console=origin; Path=/; Secure",
+ )
.body(EdgeBody::empty())
.expect("should build response");
@@ -592,7 +649,13 @@ mod tests {
"private, no-store",
"should stamp diagnostics responses non-storable"
);
- assert_eq!(response.headers()[header::SET_COOKIE], SET_CONSOLE_COOKIE);
+ let cookies = response
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .map(|value| value.to_str().expect("should emit valid cookie text"))
+ .collect::>();
+ assert_eq!(cookies, vec!["publisher=value; Path=/", SET_CONSOLE_COOKIE]);
assert!(!response.headers().contains_key("surrogate-control"));
assert!(!response.headers().contains_key("fastly-surrogate-control"));
assert!(
@@ -671,7 +734,51 @@ mod tests {
}
#[test]
- fn config_rejects_unknown_fields() {
+ fn ts_console_only_eligible_get_document_navigations_activate() {
+ for (method, destination) in [(Method::POST, "document"), (Method::GET, "script")] {
+ let mut request = Request::builder()
+ .method(method.clone())
+ .uri("https://publisher.example/page?keep=1&ts_console=1")
+ .header("sec-fetch-dest", destination)
+ .header(header::COOKIE, "__Host-ts-console=1; other=value")
+ .body(EdgeBody::empty())
+ .expect("should build ineligible request");
+
+ let decision = prepare_request(&settings(true), &mut request)
+ .expect("should sanitize ineligible request");
+
+ assert!(
+ !decision.active(),
+ "{method} {destination} must not activate"
+ );
+ assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None);
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert_eq!(request.headers()[header::COOKIE], "other=value");
+ }
+ }
+
+ #[test]
+ fn ts_console_disable_emits_the_exact_session_cookie_clear() {
+ let mut request = navigation(
+ "https://publisher.example/page?ts_console=0&keep=1",
+ Some("__Host-ts-console=1"),
+ );
+ let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
+ let mut response = Response::new(EdgeBody::empty());
+
+ finalize_response(&decision, &mut response);
+
+ assert!(!decision.active());
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert_eq!(response.headers()[header::SET_COOKIE], CLEAR_CONSOLE_COOKIE);
+ assert_eq!(
+ response.headers()[header::CACHE_CONTROL],
+ "private, no-store"
+ );
+ }
+
+ #[test]
+ fn ts_console_config_rejects_unknown_fields() {
let mut settings = create_test_settings();
settings
.integrations
diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs
index 1f4f04b73..998b83b3b 100644
--- a/crates/trusted-server-core/src/integrations/lockr.rs
+++ b/crates/trusted-server-core/src/integrations/lockr.rs
@@ -340,6 +340,7 @@ pub fn register(
IntegrationRegistration::builder(LOCKR_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration)
+ .with_empty_browser_config_v1()?
.build(),
))
}
diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs
index 90d688693..93c96a5c6 100644
--- a/crates/trusted-server-core/src/integrations/mod.rs
+++ b/crates/trusted-server-core/src/integrations/mod.rs
@@ -35,7 +35,7 @@ pub use registry::{
IntegrationRegistrationBuilder, IntegrationRegistry, IntegrationRequestFilter,
IntegrationScriptContext, IntegrationScriptRewriter, ProxyDispatchInput, RequestFilterDecision,
RequestFilterEffects, RequestFilterInput, RequestFilterRegistryInput,
- RequestFilterRegistryOutcome, ScriptRewriteAction,
+ RequestFilterRegistryOutcome, ScriptRewriteAction, TsjsCatalogSelectionV1,
};
/// Registers or retrieves a platform backend for the given URL.
@@ -99,6 +99,31 @@ pub(crate) fn ensure_integration_backend_with_timeout(
})
}
+/// Registers or retrieves a platform backend with separate response-header and
+/// response-body gap timeouts.
+///
+/// # Errors
+///
+/// Returns an error when `url` cannot be parsed, is missing a host, or the
+/// backend registration fails.
+pub(crate) fn ensure_integration_backend_with_transport_timeouts(
+ services: &RuntimeServices,
+ url: &str,
+ integration: &'static str,
+ first_byte_timeout: Duration,
+ between_bytes_timeout: Duration,
+) -> Result> {
+ let mut spec = integration_backend_spec(url, integration, true, first_byte_timeout)?;
+ spec.between_bytes_timeout = between_bytes_timeout;
+ services
+ .backend()
+ .ensure(&spec)
+ .change_context(TrustedServerError::Integration {
+ integration: integration.to_string(),
+ message: "Failed to register backend".to_string(),
+ })
+}
+
/// Compute the deterministic platform backend name for a URL without registering it.
///
/// Parses `url`, builds a [`PlatformBackendSpec`], and delegates to
diff --git a/crates/trusted-server-core/src/integrations/osano.rs b/crates/trusted-server-core/src/integrations/osano.rs
index 9fc3aa66c..659df043e 100644
--- a/crates/trusted-server-core/src/integrations/osano.rs
+++ b/crates/trusted-server-core/src/integrations/osano.rs
@@ -45,7 +45,9 @@ pub fn register(
};
Ok(Some(
- IntegrationRegistration::builder(OSANO_INTEGRATION_ID).build(),
+ IntegrationRegistration::builder(OSANO_INTEGRATION_ID)
+ .with_empty_browser_config_v1()?
+ .build(),
))
}
diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs
index aa684c620..b789b56e9 100644
--- a/crates/trusted-server-core/src/integrations/permutive.rs
+++ b/crates/trusted-server-core/src/integrations/permutive.rs
@@ -320,6 +320,7 @@ pub fn register(
IntegrationRegistration::builder(PERMUTIVE_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration)
+ .with_empty_browser_config_v1()?
.build(),
))
}
diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs
index c9b3f5ded..8f3abca7d 100644
--- a/crates/trusted-server-core/src/integrations/prebid.rs
+++ b/crates/trusted-server-core/src/integrations/prebid.rs
@@ -205,9 +205,8 @@ pub struct PrebidIntegrationConfig {
pub enabled: bool,
#[validate(url)]
pub server_url: String,
- /// Prebid Server account ID, injected into the client-side bundle via
- /// `window.__tsjs_prebid.accountId` so publishers don't need to configure
- /// it in JavaScript.
+ /// Prebid Server account ID delivered through the release-bound immutable
+ /// integration configuration so publishers do not configure it in JavaScript.
#[serde(default)]
pub account_id: Option,
#[serde(default = "default_timeout_ms")]
@@ -338,10 +337,17 @@ pub struct PrebidIntegrationConfig {
pub suppress_nurl_bidders: Vec,
}
-impl IntegrationConfig for PrebidIntegrationConfig {
- fn is_enabled(&self) -> bool {
- self.enabled
- }
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct PrebidBrowserConfigV1<'a> {
+ account_id: &'a str,
+ timeout: u32,
+ debug: bool,
+ bidders: &'a [String],
+ #[serde(skip_serializing_if = "<[String]>::is_empty")]
+ client_side_bidders: &'a [String],
+ #[serde(skip_serializing_if = "<[String]>::is_empty")]
+ excluded_gam_ad_unit_path_suffixes: &'a [String],
}
fn remove_aps_bidders(config: &mut PrebidIntegrationConfig) {
@@ -359,6 +365,12 @@ fn remove_aps_bidders(config: &mut PrebidIntegrationConfig) {
}
}
+impl IntegrationConfig for PrebidIntegrationConfig {
+ fn is_enabled(&self) -> bool {
+ self.enabled
+ }
+}
+
fn excluded_gam_ad_unit_path_suffix_validation_error(message: &'static str) -> ValidationError {
let mut error = ValidationError::new("invalid_gam_ad_unit_path_suffix");
error.message = Some(message.into());
@@ -411,19 +423,6 @@ fn canonicalize_excluded_gam_ad_unit_path_suffixes(config: &mut PrebidIntegratio
config.excluded_gam_ad_unit_path_suffixes = canonical;
}
-fn load_config(
- settings: &Settings,
-) -> Result, Report> {
- let Some(mut config) =
- settings.integration_config::(PREBID_INTEGRATION_ID)?
- else {
- return Ok(None);
- };
- canonicalize_excluded_gam_ad_unit_path_suffixes(&mut config);
- remove_aps_bidders(&mut config);
- Ok(Some(config))
-}
-
/// Validate enabled Prebid config using the same startup-only checks as runtime registration.
///
/// # Errors
@@ -433,9 +432,12 @@ fn load_config(
pub fn validate_config_for_startup(
settings: &Settings,
) -> Result, Report> {
- let Some(config) = load_config(settings)? else {
+ let Some(mut config) =
+ settings.integration_config::(PREBID_INTEGRATION_ID)?
+ else {
return Ok(None);
};
+ canonicalize_excluded_gam_ad_unit_path_suffixes(&mut config);
BidParamOverrideEngine::try_from_config(&config)?;
validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?;
Ok(Some(config))
@@ -677,6 +679,17 @@ impl PrebidIntegration {
Self::try_new(config).expect("should compile prebid bid param overrides")
}
+ fn browser_config_v1(&self) -> PrebidBrowserConfigV1<'_> {
+ PrebidBrowserConfigV1 {
+ account_id: self.config.account_id.as_deref().unwrap_or_default(),
+ timeout: self.config.timeout_ms,
+ debug: self.config.debug,
+ bidders: &self.config.bidders,
+ client_side_bidders: &self.config.client_side_bidders,
+ excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes,
+ }
+ }
+
fn auction_provider(&self) -> PrebidAuctionProvider {
PrebidAuctionProvider {
config: self.config.clone(),
@@ -955,10 +968,15 @@ fn escape_html_attr(value: &str) -> String {
fn build(
settings: &Settings,
) -> Result>, Report> {
- let Some(config) = load_config(settings)? else {
+ let Some(mut config) =
+ settings.integration_config::(PREBID_INTEGRATION_ID)?
+ else {
return Ok(None);
};
+ canonicalize_excluded_gam_ad_unit_path_suffixes(&mut config);
+ remove_aps_bidders(&mut config);
+ remove_aps_bidders(&mut config);
validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?;
// Warn about bidders that appear in both lists — this is likely a config
@@ -990,12 +1008,15 @@ pub fn register(
return Ok(None);
};
+ let browser_config = integration.browser_config_v1();
+
Ok(Some(
IntegrationRegistration::builder(PREBID_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration.clone())
- .with_head_injector(integration)
+ .with_head_injector(integration.clone())
.with_deferred_js()
+ .with_browser_config_v1(&browser_config)?
.build(),
))
}
@@ -1078,43 +1099,7 @@ impl IntegrationHeadInjector for PrebidIntegration {
}
fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec {
- #[derive(Serialize)]
- #[serde(rename_all = "camelCase")]
- struct InjectedPrebidClientConfig<'a> {
- account_id: &'a str,
- timeout: u32,
- debug: bool,
- bidders: &'a [String],
- #[serde(skip_serializing_if = "<[String]>::is_empty")]
- client_side_bidders: &'a [String],
- #[serde(skip_serializing_if = "<[String]>::is_empty")]
- excluded_gam_ad_unit_path_suffixes: &'a [String],
- }
-
- let payload = InjectedPrebidClientConfig {
- account_id: self.config.account_id.as_deref().unwrap_or_default(),
- timeout: self.config.timeout_ms,
- debug: self.config.debug,
- bidders: &self.config.bidders,
- client_side_bidders: &self.config.client_side_bidders,
- excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes,
- };
-
- // Escape `` to prevent breaking out of the script tag.
- let config_json = serde_json::to_string(&payload)
- .unwrap_or_else(|e| {
- log::warn!("Prebid: failed to serialize client config: {e}");
- "{}".to_string()
- })
- .replace("", "<\\/");
-
- let mut inserts = vec![format!(
- r#""#
- )];
-
- inserts.push(self.external_bundle_script_tag());
-
- inserts
+ vec![self.external_bundle_script_tag()]
}
}
@@ -1472,14 +1457,25 @@ fn copy_request_headers(
to: &mut http::Request,
consent_forwarding: ConsentForwardingMode,
client_ip: Option,
+ sanitized_referer: Option<&str>,
) {
- let headers_to_copy = [header::USER_AGENT, header::REFERER, header::ACCEPT_LANGUAGE];
+ let headers_to_copy = [header::USER_AGENT, header::ACCEPT_LANGUAGE];
for header_name in &headers_to_copy {
if let Some(value) = from.headers().get(header_name) {
to.headers_mut().insert(header_name, value.clone());
}
}
+ if let Some(referer) = sanitized_referer {
+ match HeaderValue::from_str(referer) {
+ Ok(value) => {
+ to.headers_mut().insert(header::REFERER, value);
+ }
+ Err(error) => {
+ log::warn!("Prebid: sanitized Referer could not be encoded as a header: {error}");
+ }
+ }
+ }
if let Some(ip) = client_ip
&& let Ok(value) = HeaderValue::from_str(&ip.to_string())
@@ -1650,23 +1646,27 @@ impl PrebidAuctionProvider {
let mut excluded_aps = false;
for (name, params) in &slot.bidders {
if name.eq_ignore_ascii_case("aps") {
- // APS is a separate OpenRTB provider. Never send native
- // APS demand through PBS for the same cohort.
+ // Trusted Server APS is a separate OpenRTB provider. Never
+ // send native APS demand through PBS for the same cohort.
excluded_aps = true;
- } else if name == TRUSTED_SERVER_BIDDER {
- for (bidder, params) in
- expand_trusted_server_bidders(&self.config.bidders, params)
- {
- if bidder.eq_ignore_ascii_case("aps") {
- excluded_aps = true;
- } else {
- expanded.insert(bidder, params);
- }
- }
- } else if self.config.bidders.iter().any(|bidder| bidder == name) {
+ continue;
+ }
+ if name == TRUSTED_SERVER_BIDDER {
+ expanded.extend(expand_trusted_server_bidders(&self.config.bidders, params));
+ // `config.bidders` may still list `aps` for operators that
+ // predate the native provider. The expansion above would
+ // fabricate a PBS entry for it, re-enabling the duplicate
+ // demand path the `aps` skip above exists to prevent.
+ expanded.retain(|name, _| {
+ let keep = !name.eq_ignore_ascii_case("aps");
+ excluded_aps |= !keep;
+ keep
+ });
+ } else if self.config.bidders.iter().any(|b| b == name) {
direct.push((name.clone(), params.clone()));
} else {
- // Any unrecognized key is likely a misconfiguration (a
+ // `aps` is intentionally handled by its own provider. Any
+ // other unrecognized key is likely a misconfiguration (a
// slot bidder absent from `config.bidders`) that silently
// yields an empty bidder map and a stored-request no-bid —
// log it so the drop is diagnosable.
@@ -1969,13 +1969,11 @@ impl PrebidAuctionProvider {
}
.to_ext();
- // Extract Referer header for site.ref
- let referer = context
- .request
- .headers()
- .get(header::REFERER)
- .and_then(|value| value.to_str().ok())
- .map(std::string::ToString::to_string);
+ // The browser Referer identifies the current publisher page for the
+ // same-origin `/auction` request, not the page that referred the user to
+ // the publisher. `request.publisher.page_url` is already sanitized and
+ // is carried as `site.page`, so do not duplicate the raw header in
+ // `site.ref`.
// Advertise the effective auction budget, not the raw provider config:
// the orchestrator caps `context.timeout_ms` to the remaining auction
@@ -1990,7 +1988,7 @@ impl PrebidAuctionProvider {
site: Some(Site {
domain: Some(request.publisher.domain.clone()),
page: page_url,
- r#ref: referer,
+ r#ref: None,
publisher: Some(Publisher {
domain: Some(request.publisher.domain.clone()),
..Default::default()
@@ -2362,9 +2360,8 @@ impl PrebidAuctionProvider {
// not an ad ID, so it is not used as a fallback: surfacing it as `ad_id`
// (which is exposed raw in the debug bid) would mislead any consumer that
// treats `ad_id` as a creative identifier. Absent `adid`, `ad_id` is None.
- // The bid ID is carried separately in `bid_id` instead. An empty `id` is
- // treated as absent — a blank hb_adid is falsey on the page, so it would
- // be no better than omitting the key.
+ // The bid ID is carried separately in `bid_id` instead. Treat a blank
+ // value as absent so it cannot become an unusable browser identity.
let bid_id = bid_obj
.get("id")
.and_then(|v| v.as_str())
@@ -2433,6 +2430,9 @@ impl PrebidAuctionProvider {
Ok(AuctionBid {
slot_id,
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(price), // Prebid provides decoded prices
currency: DEFAULT_CURRENCY.to_string(),
creative,
@@ -2564,6 +2564,7 @@ impl AuctionProvider for PrebidAuctionProvider {
&mut pbs_req,
self.config.consent_forwarding,
context.services.client_info().client_ip,
+ request.publisher.page_url.as_deref(),
);
let pbs_body = serde_json::to_vec(&openrtb).change_context(TrustedServerError::Prebid {
@@ -2695,7 +2696,8 @@ mod tests {
use crate::auction::orchestrator::OrchestrationResult;
use crate::auction::test_support::create_test_auction_context as shared_test_auction_context;
use crate::auction::types::{
- AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo,
+ AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo,
+ UserInfo,
};
use crate::consent::{ConsentContext, ConsentSource};
@@ -2713,7 +2715,7 @@ mod tests {
};
use crate::settings::Settings;
use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline};
- use crate::test_support::tests::create_test_settings;
+ use crate::test_support::tests::{bootstrap_transport, create_test_settings};
use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD;
use bytes::Bytes;
use http::Method;
@@ -2752,6 +2754,11 @@ mod tests {
}
}
+ fn browser_config_json(integration: &PrebidIntegration) -> String {
+ serde_json::to_string(&integration.browser_config_v1())
+ .expect("browser-safe Prebid config should serialize")
+ }
+
struct PredictOnlyBackend;
impl PlatformBackend for PredictOnlyBackend {
@@ -3072,7 +3079,7 @@ server_url = "https://prebid.example/openrtb2/auction"
}
#[test]
- fn startup_validation_and_runtime_build_canonicalize_excluded_gam_ad_unit_path_suffixes() {
+ fn startup_validation_canonicalizes_excluded_gam_ad_unit_path_suffixes() {
let mut settings = make_settings();
settings
.integrations
@@ -3101,24 +3108,14 @@ server_url = "https://prebid.example/openrtb2/auction"
"should retain only the first declaration of each suffix"
);
- let integration = build(&settings)
- .expect("should build Prebid integration")
- .expect("should return enabled Prebid integration");
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "pub.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
- let inserts = integration.head_inserts(&ctx);
+ let integration = PrebidIntegration::new(config);
+ let browser_config = browser_config_json(&integration);
assert!(
- inserts[0].contains(
+ browser_config.contains(
r#""excludedGamAdUnitPathSuffixes":["/trackingonly","/measurement-only"]"#
),
- "should inject the canonical suffix list: {}",
- inserts[0]
+ "should project the canonical suffix list: {browser_config}"
);
}
@@ -3278,15 +3275,25 @@ excluded_gam_ad_unit_path_suffixes = ["{suffix}"]
!processed.contains("cdn.prebid.org/prebid.js"),
"Prebid preload should be removed when auto-config is enabled"
);
- // Both scripts are `defer`, so they execute in document order. The
- // bundle must run first: the shim disables the whole integration when
- // it finds no Prebid.js API on window.pbjs.
+ // The external Prebid artifact remains distinct and precedes the one
+ // parser-time TSJS selected-runtime tag. Prebid's TS-owned adapter is included
+ // in that unified runtime artifact, never a standalone shim tag.
let bundle_index = processed
.find(PREBID_BUNDLE_ROUTE)
.expect("should inject external prebid bundle route");
let shim_index = processed
- .find("tsjs-prebid.min.js")
- .expect("should inject deferred tsjs prebid shim");
+ .find("id=\"trustedserver-js\"")
+ .expect("should inject one TSJS runtime tag");
+ let transport = bootstrap_transport(&processed);
+ let manifest_integrations = transport["boot"]["manifest"]["integrations"]
+ .as_array()
+ .expect("manifest integrations should be an array");
+ assert!(
+ manifest_integrations
+ .iter()
+ .any(|entry| { entry["id"] == "prebid" && entry["phase"] == "takeover" })
+ );
+ assert!(!processed.contains("tsjs-prebid.min.js"));
assert!(
bundle_index < shim_index,
"external prebid bundle must execute before the deferred tsjs shim"
@@ -3982,49 +3989,31 @@ external_bundle_sri = "sha384-AAAA"
}
#[test]
- fn head_injector_emits_config_script() {
+ fn registration_browser_projection_contains_only_client_fields() {
let integration = PrebidIntegration::new(base_config());
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "pub.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
- assert_eq!(inserts.len(), 2, "should produce config and bundle inserts");
-
- let script = &inserts[0];
- assert!(
- script.starts_with(""),
- "should be wrapped in script tags"
- );
+ let config = browser_config_json(&integration);
assert!(
- script.contains(r#""accountId":"test-account""#),
- "should include accountId from config: {}",
- script
+ config.contains(r#""accountId":"test-account""#),
+ "should include accountId from config: {config}"
);
assert!(
- script.contains(r#""timeout":1000"#),
- "should include timeout: {}",
- script
+ config.contains(r#""timeout":1000"#),
+ "should include timeout: {config}"
);
assert!(
- script.contains(r#""debug":false"#),
- "should include debug flag: {}",
- script
+ config.contains(r#""debug":false"#),
+ "should include debug flag: {config}"
);
assert!(
- script.contains(r#""bidders":["exampleBidder"]"#),
- "should include bidders array: {}",
- script
+ config.contains(r#""bidders":["exampleBidder"]"#),
+ "should include bidders array: {config}"
);
assert!(
- !script.contains("excludedGamAdUnitPathSuffixes"),
- "should omit empty refresh-auction exclusions: {}",
- script
+ !config.contains("excludedGamAdUnitPathSuffixes"),
+ "should omit empty refresh-auction exclusions: {config}"
);
+ assert!(!config.contains("server_url"));
+ assert!(!config.contains("external_bundle_url"));
}
#[test]
@@ -4033,22 +4022,12 @@ external_bundle_sri = "sha384-AAAA"
config.excluded_gam_ad_unit_path_suffixes =
vec!["/trackingonly".to_string(), "/measurement-only".to_string()];
let integration = PrebidIntegration::new(config);
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "pub.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
- let script = &inserts[0];
+ let config = browser_config_json(&integration);
assert!(
- script.contains(
+ config.contains(
r#""excludedGamAdUnitPathSuffixes":["/trackingonly","/measurement-only"]"#
),
- "should inject refresh-auction exclusion suffixes: {}",
- script
+ "should project refresh-auction exclusion suffixes: {config}"
);
}
@@ -4057,20 +4036,10 @@ external_bundle_sri = "sha384-AAAA"
let mut config = base_config();
config.account_id = None;
let integration = PrebidIntegration::new(config);
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "pub.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
- let script = &inserts[0];
+ let config = browser_config_json(&integration);
assert!(
- script.contains(r#""accountId":"""#),
- "should emit empty accountId when not configured: {}",
- script
+ config.contains(r#""accountId":"""#),
+ "should emit empty accountId when not configured: {config}"
);
}
@@ -4093,21 +4062,25 @@ external_bundle_sri = "sha384-AAAA"
let inserts = integration.head_inserts(&ctx);
- assert_eq!(inserts.len(), 2, "should emit config and bundle scripts");
+ assert_eq!(
+ inserts.len(),
+ 1,
+ "should emit only the vendor bundle script"
+ );
assert!(
- inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}?v={sha256}\"")),
+ inserts[0].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}?v={sha256}\"")),
"bundle script should use content-addressed first-party URL: {}",
- inserts[1]
+ inserts[0]
);
assert!(
- inserts[1].contains("integrity=\"sha384-"),
+ inserts[0].contains("integrity=\"sha384-"),
"bundle script should include configured SRI: {}",
- inserts[1]
+ inserts[0]
);
assert!(
- !inserts[1].contains("crossorigin"),
+ !inserts[0].contains("crossorigin"),
"same-origin bundle script should not include crossorigin: {}",
- inserts[1]
+ inserts[0]
);
}
@@ -4127,58 +4100,42 @@ external_bundle_sri = "sha384-AAAA"
let inserts = integration.head_inserts(&ctx);
- assert_eq!(inserts.len(), 2, "should emit config and bundle scripts");
+ assert_eq!(
+ inserts.len(),
+ 1,
+ "should emit only the vendor bundle script"
+ );
assert!(
- inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}\"")),
+ inserts[0].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}\"")),
"bundle script should use first-party route without hash query: {}",
- inserts[1]
+ inserts[0]
);
assert!(
- !inserts[1].contains("?v="),
+ !inserts[0].contains("?v="),
"unhashed bundle script should not include version query: {}",
- inserts[1]
+ inserts[0]
);
}
#[test]
- fn head_injector_escapes_closing_script_tags_in_values() {
+ fn browser_projection_preserves_script_significant_values_for_boot_escaping() {
let mut config = base_config();
config.account_id = Some("".to_string());
let integration = PrebidIntegration::new(config);
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "pub.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
- let script = &inserts[0];
+ let config = browser_config_json(&integration);
assert!(
- script.contains(r#""accountId":"<\/script>""#),
+ "typed projection should preserve data before the central boot encoder: {config}"
);
}
#[test]
fn head_injector_omits_client_side_bidders_when_empty() {
let integration = PrebidIntegration::new(base_config());
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "pub.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
- let script = &inserts[0];
+ let config = browser_config_json(&integration);
assert!(
- !script.contains("clientSideBidders"),
- "should omit clientSideBidders when empty: {}",
- script
+ !config.contains("clientSideBidders"),
+ "should omit clientSideBidders when empty: {config}"
);
}
@@ -4187,20 +4144,10 @@ external_bundle_sri = "sha384-AAAA"
let mut config = base_config();
config.client_side_bidders = vec!["rubicon".to_string(), "magnite".to_string()];
let integration = PrebidIntegration::new(config);
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "pub.example",
- request_scheme: "https",
- origin_host: "origin.example",
- document_state: &document_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
- let script = &inserts[0];
+ let config = browser_config_json(&integration);
assert!(
- script.contains(r#""clientSideBidders":["rubicon","magnite"]"#),
- "should include clientSideBidders array: {}",
- script
+ config.contains(r#""clientSideBidders":["rubicon","magnite"]"#),
+ "should include clientSideBidders array: {config}"
);
}
@@ -5250,7 +5197,7 @@ external_bundle_sri = "sha384-AAAA"
}
#[test]
- fn to_openrtb_sets_site_ref_from_referer_header() {
+ fn to_openrtb_omits_raw_referer_from_site_ref() {
let provider = PrebidAuctionProvider::new(base_config());
let auction_request = create_test_auction_request();
@@ -5270,10 +5217,14 @@ external_bundle_sri = "sha384-AAAA"
);
let site = openrtb.site.as_ref().expect("should have site");
+ assert!(
+ site.r#ref.is_none(),
+ "should not forward the raw browser Referer into site.ref"
+ );
assert_eq!(
- site.r#ref.as_deref(),
- Some("https://google.com/search?q=test"),
- "should set site.ref from Referer header"
+ site.page.as_deref(),
+ auction_request.publisher.page_url.as_deref(),
+ "should retain the sanitized publisher page URL"
);
}
@@ -5763,6 +5714,11 @@ external_bundle_sri = "sha384-AAAA"
provider_responses: vec![provider_response],
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: crate::auction::types::AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "test-auction".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 42,
metadata: HashMap::new(),
};
@@ -5892,6 +5848,37 @@ external_bundle_sri = "sha384-AAAA"
provider.to_openrtb(request, &context, None, request_info)
}
+ fn call_request_bids(
+ config: PrebidIntegrationConfig,
+ request: &AuctionRequest,
+ ) -> (
+ Result>,
+ Arc,
+ ) {
+ let provider = PrebidAuctionProvider::new(config);
+ let settings = make_settings();
+ let http_req = http::Request::builder()
+ .method(http::Method::POST)
+ .uri("https://example.com/auction")
+ .body(EdgeBody::empty())
+ .expect("should build request");
+ let stub = Arc::new(StubHttpClient::new());
+ let services = build_services_with_http_client(
+ Arc::clone(&stub) as Arc
+ );
+ let context = AuctionContext {
+ settings: &settings,
+ request: &http_req,
+ timeout_ms: 1000,
+ provider_responses: None,
+ services: &services,
+ };
+ (
+ futures::executor::block_on(provider.request_bids(request, &context)),
+ stub,
+ )
+ }
+
fn bidder_params(ortb: &OpenRtbRequest) -> &serde_json::Map {
let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext");
ext.get("prebid")
@@ -6961,6 +6948,72 @@ set = { placementId = "explicit_header" }
// PBS stored request tests
// ========================================================================
+ #[test]
+ fn to_openrtb_drops_aps_only_slots_instead_of_using_stored_requests() {
+ for bidder in ["aps", "APS", "Aps"] {
+ let slot = make_slot(
+ "atf_sidebar_ad",
+ HashMap::from([(
+ bidder.to_string(),
+ json!({"slotID": "aps-slot-atf-sidebar"}),
+ )]),
+ );
+ let request = make_auction_request(vec![slot]);
+
+ let openrtb = call_to_openrtb(base_config(), &request);
+ assert!(
+ openrtb.imp.is_empty(),
+ "should drop APS-only slot for case variant {bidder}"
+ );
+ }
+ }
+
+ #[test]
+ fn request_bids_returns_no_bid_without_pbs_for_aps_only_slots() {
+ for bidder in ["aps", "APS", "Aps"] {
+ let slot = make_slot(
+ "atf_sidebar_ad",
+ HashMap::from([(
+ bidder.to_string(),
+ json!({"slotID": "aps-slot-atf-sidebar"}),
+ )]),
+ );
+ let request = make_auction_request(vec![slot]);
+ let (outcome, stub) = call_request_bids(base_config(), &request);
+ let ProviderRequestOutcome::Immediate(response) =
+ outcome.expect("APS-only request should be a normal outcome")
+ else {
+ panic!("APS-only request should complete immediately");
+ };
+
+ assert_eq!(response.status, BidStatus::NoBid);
+ assert!(response.bids.is_empty());
+ assert!(response.metadata.is_empty());
+ assert!(
+ stub.recorded_backend_names().is_empty(),
+ "APS-only variant {bidder} should not contact PBS"
+ );
+ }
+ }
+
+ #[test]
+ fn request_bids_keeps_mixed_aps_only_and_invalid_slots_as_an_error() {
+ let aps_slot = make_slot(
+ "aps-slot",
+ HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot"}))]),
+ );
+ let mut invalid_slot = make_slot("invalid-slot", HashMap::new());
+ invalid_slot.formats[0].width = u32::MAX;
+ let request = make_auction_request(vec![aps_slot, invalid_slot]);
+ let (outcome, stub) = call_request_bids(base_config(), &request);
+
+ assert!(
+ outcome.is_err(),
+ "mixed invalid input should remain an error"
+ );
+ assert!(stub.recorded_backend_names().is_empty());
+ }
+
#[test]
fn to_openrtb_uses_stored_request_when_slot_has_empty_bidders() {
let slot = make_slot("homepage_header_ad", HashMap::new());
@@ -7290,17 +7343,68 @@ bidders = ["kargo", "triplelift"]
}
#[test]
- fn to_openrtb_drops_aps_only_demand_instead_of_using_stored_request() {
- let slot = make_slot(
+ fn to_openrtb_drops_aps_only_trusted_server_expansion() {
+ let mut config = base_config();
+ config.bidders = vec!["APS".to_string()];
+ let slot = make_ts_slot(
"atf_sidebar_ad",
- HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot-atf-sidebar"}))]),
+ &json!({"APS": {"slotID": "aps-slot-atf-sidebar"}}),
+ None,
);
let request = make_auction_request(vec![slot]);
- let ortb = call_to_openrtb(base_config(), &request);
+ let openrtb = call_to_openrtb(config, &request);
assert!(
- ortb.imp.is_empty(),
- "should not fall back to a PBS stored request after excluding APS-only demand"
+ openrtb.imp.is_empty(),
+ "should not fall back to a stored request after excluding APS expansion"
+ );
+ }
+
+ #[test]
+ fn request_bids_returns_no_bid_for_aps_only_trusted_server_expansion() {
+ let mut config = base_config();
+ config.bidders = vec!["APS".to_string()];
+ let slot = make_ts_slot(
+ "atf_sidebar_ad",
+ &json!({"APS": {"slotID": "aps-slot-atf-sidebar"}}),
+ None,
+ );
+ let request = make_auction_request(vec![slot]);
+ let (outcome, stub) = call_request_bids(config, &request);
+ let ProviderRequestOutcome::Immediate(response) =
+ outcome.expect("APS-only expansion should be a normal outcome")
+ else {
+ panic!("APS-only expansion should complete immediately");
+ };
+
+ assert_eq!(response.status, BidStatus::NoBid);
+ assert!(stub.recorded_backend_names().is_empty());
+ }
+
+ #[test]
+ fn trusted_server_expansion_never_enables_aps_through_pbs() {
+ let mut config = base_config();
+ config.bidders = vec!["kargo".to_string(), "APS".to_string()];
+ let slot = make_ts_slot(
+ "in_content_ad",
+ &json!({
+ "kargo": {"placementId": "client_123"},
+ "APS": {"slotID": "legacy-aps-slot"}
+ }),
+ None,
+ );
+ let request = make_auction_request(vec![slot]);
+
+ let ortb = call_to_openrtb(config, &request);
+ let bidder = &ortb.imp[0].ext.as_ref().expect("should have imp ext")["prebid"]["bidder"];
+ assert_eq!(bidder["kargo"]["placementId"], "client_123");
+ assert!(
+ bidder
+ .as_object()
+ .expect("should serialize bidder map")
+ .keys()
+ .all(|name| !name.eq_ignore_ascii_case("aps")),
+ "Trusted Server APS cohorts must not also send APS through PBS"
);
}
@@ -7571,6 +7675,7 @@ set = { networkId = 42 }
"id": "bid-impression-id",
"impid": "atf_sidebar_ad",
"adid": "bidder-ad-id-abc",
+ "crid": "bidder-creative-id-abc",
"price": 1.0,
"w": 300,
"h": 250,
@@ -7589,11 +7694,21 @@ set = { networkId = 42 }
let bid = provider
.parse_bid(&bid_json, "appnexus")
.expect("should parse bid");
+ assert_eq!(
+ bid.bid_id.as_deref(),
+ Some("bid-impression-id"),
+ "should preserve OpenRTB id separately"
+ );
assert_eq!(
bid.ad_id.as_deref(),
Some("bidder-ad-id-abc"),
"should keep ad_id from adid field"
);
+ assert_eq!(
+ bid.creative_id.as_deref(),
+ Some("bidder-creative-id-abc"),
+ "should preserve OpenRTB crid separately"
+ );
assert_eq!(
bid.cache_id.as_deref(),
Some("cache-uuid-xyz"),
@@ -7636,8 +7751,6 @@ set = { networkId = 42 }
#[test]
fn parse_bid_treats_blank_bid_id_as_absent() {
- // A blank hb_adid is falsey on the page, so carrying an empty `id`
- // forward would be no better than omitting the field.
let bid_json = serde_json::json!({
"id": "",
"impid": "atf_sidebar_ad",
@@ -7649,6 +7762,7 @@ set = { networkId = 42 }
let bid = provider
.parse_bid(&bid_json, "example-bidder")
.expect("should parse bid");
+
assert!(
bid.bid_id.is_none(),
"should treat an empty OpenRTB bid id as absent"
@@ -7660,6 +7774,10 @@ set = { networkId = 42 }
let from = http::Request::builder()
.uri("https://publisher.example.com/")
.header("x-forwarded-for", "6.6.6.6")
+ .header(
+ header::REFERER,
+ "https://publisher.example.com/article?email=private#fragment",
+ )
.header(header::USER_AGENT, "test-agent")
.body(EdgeBody::empty())
.expect("should build inbound request");
@@ -7673,6 +7791,7 @@ set = { networkId = 42 }
&mut to,
ConsentForwardingMode::Both,
Some(std::net::IpAddr::from([203, 0, 113, 7])),
+ Some("https://publisher.example.com/article"),
);
assert_eq!(
@@ -7689,6 +7808,13 @@ set = { networkId = 42 }
Some("test-agent"),
"should still copy the browser User-Agent"
);
+ assert_eq!(
+ to.headers()
+ .get(header::REFERER)
+ .and_then(|value| value.to_str().ok()),
+ Some("https://publisher.example.com/article"),
+ "should replace the raw browser Referer with the sanitized page URL"
+ );
}
#[test]
@@ -7703,7 +7829,7 @@ set = { networkId = 42 }
.body(EdgeBody::empty())
.expect("should build outbound request");
- copy_request_headers(&from, &mut to, ConsentForwardingMode::Both, None);
+ copy_request_headers(&from, &mut to, ConsentForwardingMode::Both, None, None);
assert!(
!to.headers().contains_key("x-forwarded-for"),
diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs
index 280eae847..ab7d21a40 100644
--- a/crates/trusted-server-core/src/integrations/registry.rs
+++ b/crates/trusted-server-core/src/integrations/registry.rs
@@ -1,5 +1,5 @@
use std::any::{Any, TypeId};
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
@@ -7,6 +7,7 @@ use edgezero_core::body::Body as EdgeBody;
use error_stack::Report;
use http::{Method, Request, Response};
use matchit::Router;
+use serde::Serialize;
use crate::constants::HEADER_X_TS_EC;
use crate::ec::EcContext;
@@ -575,11 +576,6 @@ pub trait IntegrationHeadInjector: Send + Sync {
fn integration_id(&self) -> &'static str;
/// Return HTML snippets to insert at the start of ``.
fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec;
-
- /// Return attributes to add to the publisher TSJS bundle tag.
- fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> {
- Vec::new()
- }
}
/// Registration payload returned by integration builders.
@@ -593,6 +589,7 @@ pub struct IntegrationRegistration {
pub html_post_processors: Vec>,
pub head_injectors: Vec>,
pub request_filters: Vec>,
+ pub(crate) browser_config_v1: Option,
}
impl IntegrationRegistration {
@@ -619,6 +616,7 @@ impl IntegrationRegistrationBuilder {
html_post_processors: Vec::new(),
head_injectors: Vec::new(),
request_filters: Vec::new(),
+ browser_config_v1: None,
},
}
}
@@ -665,6 +663,47 @@ impl IntegrationRegistrationBuilder {
self
}
+ /// Attach this product's explicit browser-safe version-1 projection.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error when the typed projection cannot be represented as JSON.
+ pub(crate) fn with_browser_config_v1(
+ mut self,
+ config: &T,
+ ) -> Result> {
+ self.registration.browser_config_v1 =
+ Some(serde_json::to_value(config).map_err(|error| {
+ Report::new(TrustedServerError::Configuration {
+ message: format!(
+ "Integration {} browser config serialization failed: {error}",
+ self.registration.integration_id
+ ),
+ })
+ })?);
+ Ok(self)
+ }
+
+ /// Attach an exact empty object for a product with no browser-selectable fields.
+ pub(crate) fn with_empty_browser_config_v1(
+ mut self,
+ ) -> Result> {
+ #[derive(Serialize)]
+ struct EmptyBrowserConfigV1 {}
+
+ self.registration.browser_config_v1 = Some(
+ serde_json::to_value(EmptyBrowserConfigV1 {}).map_err(|error| {
+ Report::new(TrustedServerError::Configuration {
+ message: format!(
+ "Integration {} browser config serialization failed: {error}",
+ self.registration.integration_id
+ ),
+ })
+ })?,
+ );
+ Ok(self)
+ }
+
/// Mark this integration's JS module for deferred loading via
/// `",
- self.config.rewrite_sdk
- )];
-
- if !self.config.rewrite_sdk {
- return inserts;
- }
-
- // Install a property trap on `window._sp_` so that when the
- // publisher's code (typically a Next.js hydration chunk) sets the
- // Sourcepoint config object, we intercept it and rewrite any
- // `cdn.privacy-mgmt.com` URLs to the first-party proxy prefix.
- //
- // The trap is transparent: the getter returns the (patched) value and
- // the setter accepts any shape the SDK expects. We also handle the
- // case where `window._sp_` is already set before our script runs.
- //
- // Limitations:
- // - Only intercepts top-level assignment (`window._sp_ = …`). Nested
- // mutation like `window._sp_.config.baseEndpoint = "…"` after the
- // initial assignment is not caught. The JS body regex rewriter
- // covers that case for string literals in bundled code.
- // - `s.replace()` replaces only the first occurrence per call, which
- // is fine for the current set of scalar URL config fields.
- inserts.push(format!(
- concat!(
- "",
- ),
- cdn_host = SOURCEPOINT_CDN_HOST,
- cdn_prefix = SOURCEPOINT_CDN_PREFIX,
- ));
-
- inserts
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
- use crate::integrations::{IntegrationDocumentState, IntegrationRegistry};
+ use crate::integrations::IntegrationRegistry;
use crate::test_support::tests::create_test_settings;
use serde_json::json;
@@ -1444,92 +1383,43 @@ mod tests {
}
#[test]
- fn head_injector_emits_config_script_plus_trap_when_enabled() {
- let integration = SourcepointIntegration::new(Arc::new(config(true)));
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "ts.example.com",
- request_scheme: "https",
- origin_host: "origin.example.com",
- document_state: &document_state,
- };
+ fn registration_projects_rewrite_enabled_without_a_config_script() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ SOURCEPOINT_INTEGRATION_ID,
+ &json!({ "enabled": true, "rewrite_sdk": true }),
+ )
+ .expect("should insert Sourcepoint config");
+ let registration = register(&settings)
+ .expect("config should parse")
+ .expect("Sourcepoint should register");
- let inserts = integration.head_inserts(&ctx);
+ assert!(registration.head_injectors.is_empty());
assert_eq!(
- inserts.len(),
- 2,
- "should emit config plus trap script when enabled"
- );
-
- let config_script = &inserts[0];
- assert!(
- config_script.contains("window.__tsjs_sourcepoint={\"rewriteSdk\":true}"),
- "should emit rewrite SDK config script: {config_script}"
- );
-
- let trap_script = &inserts[1];
- assert!(
- trap_script.starts_with(""),
- "should be wrapped in script tags: {trap_script}",
- );
- assert!(
- trap_script.contains("cdn.privacy-mgmt.com"),
- "should reference the CDN host to rewrite: {trap_script}",
- );
- assert!(
- trap_script.contains("/integrations/sourcepoint/cdn"),
- "should contain the first-party CDN prefix: {trap_script}",
- );
- assert!(
- trap_script.contains("try{") && trap_script.contains("catch(e)"),
- "should guard best-effort trap installation: {trap_script}",
- );
- assert!(
- trap_script.contains("console.warn"),
- "should log trap installation failures for observability: {trap_script}",
- );
- assert!(
- trap_script.contains("Object.defineProperty"),
- "should install a property trap on window._sp_: {trap_script}",
- );
- for config_field in ["baseEndpoint", "mmsDomain", "wrapperAPIOrigin", "cmpOrigin"] {
- assert!(
- trap_script.contains(&format!("o.config.{config_field}")),
- "should patch config field {config_field}: {trap_script}",
- );
- }
- assert!(
- trap_script.contains("o.metricUrl"),
- "should patch top-level metricUrl: {trap_script}",
+ registration.browser_config_v1,
+ Some(json!({ "rewriteSdk": true }))
);
}
#[test]
- fn head_injector_returns_config_when_rewrite_disabled() {
- let mut cfg = config(true);
- cfg.rewrite_sdk = false;
- let integration = SourcepointIntegration::new(Arc::new(cfg));
- let document_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "ts.prospecta.com",
- request_scheme: "https",
- origin_host: "origin.prospecta.com",
- document_state: &document_state,
- };
+ fn registration_projects_rewrite_disabled() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ SOURCEPOINT_INTEGRATION_ID,
+ &json!({ "enabled": true, "rewrite_sdk": false }),
+ )
+ .expect("should insert Sourcepoint config");
+ let registration = register(&settings)
+ .expect("config should parse")
+ .expect("Sourcepoint should register");
- let inserts = integration.head_inserts(&ctx);
assert_eq!(
- inserts.len(),
- 1,
- "should emit only config script when rewrite_sdk is false"
- );
- assert!(
- inserts[0].contains("window.__tsjs_sourcepoint={\"rewriteSdk\":false}"),
- "should flag rewriteSdk false"
- );
- assert!(
- !inserts[0].contains("Object.defineProperty"),
- "should not emit runtime trap when rewrite_sdk is disabled"
+ registration.browser_config_v1,
+ Some(json!({ "rewriteSdk": false }))
);
}
diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs
index 80b2c4dfa..c1760cb48 100644
--- a/crates/trusted-server-core/src/integrations/testlight.rs
+++ b/crates/trusted-server-core/src/integrations/testlight.rs
@@ -158,6 +158,7 @@ pub fn register(
IntegrationRegistration::builder(TESTLIGHT_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration)
+ .with_empty_browser_config_v1()?
.build(),
))
}
diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs
index 48e92faed..aa3c3c4ee 100644
--- a/crates/trusted-server-core/src/lib.rs
+++ b/crates/trusted-server-core/src/lib.rs
@@ -71,6 +71,7 @@ pub mod streaming_processor;
pub mod streaming_replacer;
pub mod test_support;
pub mod tester_cookie;
+pub mod trace_cookie;
pub mod tsjs;
#[cfg(test)]
diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs
index 4aded7488..c27a5c988 100644
--- a/crates/trusted-server-core/src/openrtb.rs
+++ b/crates/trusted-server-core/src/openrtb.rs
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
-use crate::auction::types::{BidRenderer, OrchestratorExt};
+use crate::auction::types::{BidRenderSourceV1, OrchestratorExt};
pub type OpenRtbRequest = trusted_server_openrtb::BidRequest;
pub type OpenRtbResponse = trusted_server_openrtb::BidResponse;
@@ -180,7 +180,7 @@ impl ToExt for BidExt<'_> {}
#[derive(Debug, Serialize)]
pub struct BidTrustedServerExt<'a> {
- pub renderer: &'a BidRenderer,
+ pub renderer: &'a BidRenderSourceV1,
}
#[derive(Debug, Serialize)]
diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs
index c93cd757d..c1c3711ed 100644
--- a/crates/trusted-server-core/src/platform/http.rs
+++ b/crates/trusted-server-core/src/platform/http.rs
@@ -1,5 +1,6 @@
use std::any::Any;
use std::fmt;
+use std::time::Duration;
use edgezero_core::http::{Request as EdgeRequest, Response as EdgeResponse};
use error_stack::Report;
@@ -7,6 +8,64 @@ use error_stack::Report;
use super::PlatformError;
use super::image_optimizer::PlatformImageOptimizerOptions;
+/// Raw evidence for one security-relevant upstream response header.
+///
+/// Values are never split or normalized. A runtime that preserves duplicate
+/// fields returns every occurrence; a runtime that visibly combines fields
+/// returns that exact combined byte string; erased evidence is unavailable.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ProxyHeaderEvidenceV1 {
+ /// Every raw header occurrence, including an empty vector for known absence.
+ Occurrences(Vec>),
+ /// One exact runtime-combined value. Core must not split it.
+ Combined(Vec),
+ /// The runtime erased or ambiguously transformed the evidence.
+ Unavailable,
+}
+
+impl ProxyHeaderEvidenceV1 {
+ /// Known absence of the header.
+ #[must_use]
+ pub fn absent() -> Self {
+ Self::Occurrences(Vec::new())
+ }
+
+ /// One preserved raw header occurrence.
+ #[must_use]
+ pub fn one(value: impl Into>) -> Self {
+ Self::Occurrences(vec![value.into()])
+ }
+}
+
+/// Status and raw security-header evidence captured before adapter normalization.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProxyResponseEvidenceV1 {
+ pub status: u16,
+ pub content_type: ProxyHeaderEvidenceV1,
+ pub content_encoding: ProxyHeaderEvidenceV1,
+ pub content_length: ProxyHeaderEvidenceV1,
+}
+
+/// Dedicated bounded raw-proxy transport policy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RawProxyPolicyV1 {
+ /// Monotonic deadline covering dispatch through the final body byte.
+ pub total_timeout: Duration,
+ /// Maximum time the transport may wait for response headers.
+ pub first_byte_timeout: Duration,
+ /// Maximum duration of one blocking response-body read.
+ pub blocking_read_timeout: Duration,
+ /// Maximum accepted identity-body bytes.
+ pub max_response_bytes: usize,
+}
+
+/// Byte-preserving response produced by the dedicated raw-proxy transport.
+#[derive(Debug)]
+pub struct RawProxyResponseV1 {
+ pub evidence: ProxyResponseEvidenceV1,
+ pub body: Vec,
+}
+
/// Outbound HTTP request paired with a pre-resolved backend name.
///
/// Uses `EdgeZero`'s neutral [`EdgeRequest`] type so adapters share one
@@ -266,6 +325,21 @@ pub trait PlatformHttpClient: Send + Sync {
request: PlatformHttpRequest,
) -> Result>;
+ /// Send one response-evidence-preserving, byte-bounded raw proxy request.
+ ///
+ /// Adapters must disable redirects and transformations, capture the three
+ /// evidence headers before generic normalization, enforce the total
+ /// monotonic deadline and byte cap, and cancel/drop in-flight resources on
+ /// failure. The default fails closed for platforms without that contract.
+ async fn send_raw_proxy_v1(
+ &self,
+ _request: PlatformHttpRequest,
+ _policy: RawProxyPolicyV1,
+ ) -> Result> {
+ Err(Report::new(PlatformError::Unsupported)
+ .attach("bounded raw-proxy transport is unavailable on this platform"))
+ }
+
/// Start an upstream request without waiting for it to complete.
///
/// # Errors
diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs
index 1c5bf4c2a..2f511eb31 100644
--- a/crates/trusted-server-core/src/platform/mod.rs
+++ b/crates/trusted-server-core/src/platform/mod.rs
@@ -49,7 +49,8 @@ pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformK
pub use error::PlatformError;
pub use http::{
PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse,
- PlatformSelectResult, UnavailableHttpClient,
+ PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1,
+ RawProxyResponseV1, UnavailableHttpClient,
};
pub use image_optimizer::{
PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions,
@@ -68,6 +69,7 @@ pub use template_cache::{
VaryHeaderValues, VarySpec,
};
pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore};
+pub use types::ExactResponseHeadersV1;
pub use types::{
ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId,
StoreName,
diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs
index 917f1bf50..f9c8a7145 100644
--- a/crates/trusted-server-core/src/platform/test_support.rs
+++ b/crates/trusted-server-core/src/platform/test_support.rs
@@ -11,7 +11,8 @@ use super::{
ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError,
PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerOptions,
PlatformImageOptimizerParams, PlatformPendingRequest, PlatformResponse, PlatformSecretStore,
- PlatformSelectResult, RuntimeServices, StoreId, StoreName,
+ PlatformSelectResult, RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId,
+ StoreName,
};
use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME};
@@ -234,6 +235,8 @@ pub(crate) struct StubHttpClient {
request_uris: Mutex>,
// Outgoing request bodies captured per send call, collected to bytes.
request_bodies: Mutex>>,
+ raw_proxy_responses: Mutex>,
+ raw_proxy_policies: Mutex>,
}
struct StubHttpResponse {
@@ -257,6 +260,8 @@ impl StubHttpClient {
request_methods: Mutex::new(Vec::new()),
request_uris: Mutex::new(Vec::new()),
request_bodies: Mutex::new(Vec::new()),
+ raw_proxy_responses: Mutex::new(VecDeque::new()),
+ raw_proxy_policies: Mutex::new(Vec::new()),
}
}
@@ -298,6 +303,22 @@ impl StubHttpClient {
});
}
+ /// Queue one response for the dedicated raw-proxy transport boundary.
+ pub fn push_raw_proxy_response(&self, response: RawProxyResponseV1) {
+ self.raw_proxy_responses
+ .lock()
+ .expect("should lock raw proxy responses")
+ .push_back(response);
+ }
+
+ /// Return raw-proxy policies captured per dedicated send.
+ pub fn recorded_raw_proxy_policies(&self) -> Vec {
+ self.raw_proxy_policies
+ .lock()
+ .expect("should lock raw proxy policies")
+ .clone()
+ }
+
/// Inject a `select()` error: the next call to `select()` will return
/// `ready: Err(...)` with the failed request's backend name in
/// `failed_backend_name`. The corresponding queued response is consumed.
@@ -467,6 +488,77 @@ impl PlatformHttpClient for StubHttpClient {
Ok(PlatformResponse::new(edge_response))
}
+ 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 StubHttpClient raw proxy request"));
+ }
+
+ self.calls
+ .lock()
+ .expect("should lock calls")
+ .push(request.backend_name.clone());
+ self.raw_proxy_policies
+ .lock()
+ .expect("should lock raw proxy policies")
+ .push(policy);
+ self.cache_bypass_flags
+ .lock()
+ .expect("should lock cache bypass flags")
+ .push(request.bypass_cache);
+ self.request_methods
+ .lock()
+ .expect("should lock request methods")
+ .push(request.request.method().to_string());
+ self.request_uris
+ .lock()
+ .expect("should lock request URIs")
+ .push(request.request.uri().to_string());
+ self.request_headers
+ .lock()
+ .expect("should lock request headers")
+ .push(
+ request
+ .request
+ .headers()
+ .iter()
+ .map(|(name, value)| {
+ (
+ name.as_str().to_string(),
+ String::from_utf8_lossy(value.as_bytes()).into_owned(),
+ )
+ })
+ .collect(),
+ );
+
+ let (_, body) = request.request.into_parts();
+ let body = body
+ .into_bytes_bounded(MAX_RECORDED_BODY_BYTES)
+ .await
+ .change_context(PlatformError::HttpClient)?
+ .to_vec();
+ self.request_bodies
+ .lock()
+ .expect("should lock request bodies")
+ .push(body);
+
+ let response = self
+ .raw_proxy_responses
+ .lock()
+ .expect("should lock raw proxy responses")
+ .pop_front()
+ .ok_or_else(|| Report::new(PlatformError::HttpClient))?;
+ if response.body.len() > policy.max_response_bytes {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("stub raw proxy body exceeds configured cap"));
+ }
+ Ok(response)
+ }
+
async fn send_async(
&self,
request: PlatformHttpRequest,
diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs
index 7a3d09334..d7d25c1fa 100644
--- a/crates/trusted-server-core/src/platform/types.rs
+++ b/crates/trusted-server-core/src/platform/types.rs
@@ -10,6 +10,15 @@ use super::{
PlatformSecretStore,
};
+/// Response-extension marker for routes whose security contract owns the
+/// complete application-header set.
+///
+/// Adapter finalizers must not append geo, deployment, or operator-configured
+/// headers to a marked response. HTTP runtimes may still add transport framing
+/// such as `Content-Length`.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ExactResponseHeadersV1;
+
/// Geographic information extracted from a request.
///
/// Serde derives are required because `GeoInfo` is embedded in
diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs
index a288a9c17..1bc802f55 100644
--- a/crates/trusted-server-core/src/publisher.rs
+++ b/crates/trusted-server-core/src/publisher.rs
@@ -10,6 +10,9 @@
//! streaming processor treats unknown encodings as identity, so publisher code
//! must gate them out before the body enters the rewrite pipeline.
//!
+//! `BootManifestV1` serialization remains a pure helper in Task 8. This
+//! production pipeline does not emit it until the coordinated Task 19 switch.
+//!
//! **Note on platform coupling:** The handler boundaries use portable HTTP
//! types: [`handle_publisher_request`] and [`stream_publisher_body`] take and
//! return `http::Request`/`http::Response` over `EdgeBody`, and platform I/O is
@@ -19,41 +22,41 @@
//! the streaming route). It is not a content-rewriting concern.
use std::borrow::Cow;
+use std::collections::{BTreeMap, HashSet};
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
-use brotli::Decompressor;
-use brotli::enc::BrotliEncoderParams;
-use brotli::enc::writer::CompressorWriter;
use cookie::CookieJar;
use edgezero_core::body::Body as EdgeBody;
use error_stack::{Report, ResultExt};
-use flate2::read::ZlibDecoder;
-use flate2::write::{GzEncoder, ZlibEncoder};
use futures::StreamExt as _;
use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header};
use crate::auction::endpoints::{
merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids,
};
-use crate::auction::formats::sanitize_publisher_page_url;
+use crate::auction::formats::{
+ coordinated_cutover_v1::CanonicalBrowserAuctionProjectionV1, sanitize_publisher_page_url,
+};
use crate::auction::orchestrator::{
- AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, ERROR_TYPE_ALL,
- ERROR_TYPE_HTTP_STATUS, ERROR_TYPE_LAUNCH_FAILED, ERROR_TYPE_PARSE_RESPONSE,
- ERROR_TYPE_TIMEOUT, ERROR_TYPE_TRANSPORT,
+ AuctionOrchestrator, DispatchedAuction, ERROR_TYPE_ALL, ERROR_TYPE_HTTP_STATUS,
+ ERROR_TYPE_LAUNCH_FAILED, ERROR_TYPE_PARSE_RESPONSE, ERROR_TYPE_TIMEOUT, ERROR_TYPE_TRANSPORT,
+ OrchestrationResult,
};
use crate::auction::telemetry::{
AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events,
emit_auction_events_best_effort_lazy,
};
use crate::auction::types::{
- AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo,
-};
-use crate::cache_policy::{
- CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store,
+ AdmRenderSourceV1, AuctionContext, AuctionDecisionSetV1, AuctionDropReasons,
+ AuctionIdentityGenerator, AuctionRequest, AuctionSlotFailureReason, BaselinePbsCacheSourceV1,
+ Bid, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, BrowserAuctionSlotV1,
+ DeviceInfo, PublisherInfo, SiteInfo, SlotAuctionDecisionV1, SystemAuctionIdentityGenerator,
+ UserInfo, mint_response_unique_base64url_identity,
};
+use crate::cache_policy::{EdgeCacheHeader, cache_control_headers_are_private_or_no_store};
use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent};
use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT};
use crate::cookies::handle_request_cookies;
@@ -65,6 +68,7 @@ use crate::error::TrustedServerError;
use crate::html_processor::BodyCloseInjection;
use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag};
use crate::integrations::IntegrationRegistry;
+use crate::integrations::aps::ApsConfig;
use crate::platform::{
GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, VarySpec,
contains_publisher_esi_directive,
@@ -80,8 +84,8 @@ use crate::settings::{
AuctionDebugCommentFormat, AuctionDebugCommentOptions, AuctionDebugCommentVerbosity, Settings,
};
use crate::streaming_processor::{
- BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig,
- STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline,
+ BodyStreamDecoder, BodyStreamEncoder, Compression, PipelineConfig, STREAM_CHUNK_SIZE,
+ StreamProcessor, StreamingPipeline,
};
use crate::streaming_replacer::create_url_replacer;
@@ -89,6 +93,30 @@ const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"];
const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15);
const HEADER_X_TS_TEMPLATE_CACHE: &str = "x-ts-template-cache";
const HEADER_X_TS_ASSEMBLY: &str = "x-ts-assembly";
+const APS_PUBLISHER_FRAME_POLICY: &str = "frame-ancestors 'self'";
+
+fn append_aps_publisher_frame_policy(response: &mut Response, aps_enabled: bool) {
+ if aps_enabled {
+ response.headers_mut().append(
+ header::CONTENT_SECURITY_POLICY,
+ HeaderValue::from_static(APS_PUBLISHER_FRAME_POLICY),
+ );
+ }
+}
+
+fn ensure_aps_publisher_frame_policy(response: &mut Response, aps_enabled: bool) {
+ if !aps_enabled {
+ return;
+ }
+ let already_present = response
+ .headers()
+ .get_all(header::CONTENT_SECURITY_POLICY)
+ .iter()
+ .any(|value| value.as_bytes() == APS_PUBLISHER_FRAME_POLICY.as_bytes());
+ if !already_present {
+ append_aps_publisher_frame_policy(response, true);
+ }
+}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TemplateCacheResponseState {
@@ -278,6 +306,14 @@ fn not_found_response() -> Response {
response
}
+fn tsjs_not_found_response() -> Response {
+ let mut response = not_found_response();
+ response
+ .headers_mut()
+ .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
+ response
+}
+
fn restrict_accept_encoding(req: &mut Request) {
// If the client sent no Accept-Encoding, leave the request unchanged so the
// origin responds without compression. Adding encodings here would cause the
@@ -371,10 +407,9 @@ fn parse_quality_value(value: &str) -> Option {
let (whole, fraction) = value
.split_once('.')
.map_or((value, None), |(whole, fraction)| (whole, Some(fraction)));
- let fraction_is_valid = fraction.is_none_or(|fraction| {
- fraction.len() <= 3 && fraction.bytes().all(|byte| byte.is_ascii_digit())
- });
- if !fraction_is_valid {
+ if fraction.is_some_and(|fraction| {
+ fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit())
+ }) {
return None;
}
match whole {
@@ -438,11 +473,8 @@ fn negotiate_reader_compression(
};
let wildcard = explicit("*");
let quality_for = |name: &str| explicit(name).or(wildcard).unwrap_or(0.0);
- // Identity is implicitly acceptable at q=1 unless explicitly excluded, or a
- // wildcard q=0 excludes every unlisted coding.
let identity_quality =
explicit("identity").unwrap_or_else(|| if wildcard == Some(0.0) { 0.0 } else { 1.0 });
-
let candidates = [
(Compression::Brotli, quality_for("br")),
(Compression::Gzip, quality_for("gzip")),
@@ -512,13 +544,7 @@ fn encode_complete_body(
Ok(encoded)
}
-/// Unified tsjs static serving: `/static/tsjs=`
-///
-/// Serves two types of bundles:
-/// - **Unified bundle** (`tsjs-unified.min.js`): core + immediate (non-deferred)
-/// integration modules.
-/// - **Deferred module** (`tsjs-{id}.min.js`): a single self-contained IIFE for
-/// modules loaded with `defer` (e.g., prebid).
+/// Exact content-addressed TSJS release transport.
///
/// # Errors
///
@@ -529,74 +555,152 @@ pub fn handle_tsjs_dynamic(
edge_header: EdgeCacheHeader,
) -> Result, Report> {
const PREFIX: &str = "/static/tsjs=";
- const UNIFIED_FILENAMES: &[&str] = &["tsjs-unified.js", "tsjs-unified.min.js"];
let path = req.uri().path();
- if !path.starts_with(PREFIX) {
- return Ok(not_found_response());
+ if (req.method() != Method::GET && req.method() != Method::HEAD) || !path.starts_with(PREFIX) {
+ return Ok(tsjs_not_found_response());
}
let filename = &path[PREFIX.len()..];
- if UNIFIED_FILENAMES.contains(&filename) {
- // Serve core + immediate modules (excludes deferred like prebid).
- let module_ids = integration_registry.js_module_ids_immediate();
- let body = trusted_server_js::concatenate_modules(&module_ids);
- let hash = trusted_server_js::concatenated_hash(&module_ids);
- return Ok(serve_tsjs_static(req, &body, &hash, edge_header));
+ if filename == "tsjs-first-display.min.js" {
+ let Some((mask, requested_hash)) = req
+ .uri()
+ .query()
+ .and_then(parse_first_display_artifact_query)
+ else {
+ return Ok(tsjs_not_found_response());
+ };
+ let Some(artifact) = integration_registry.tsjs_first_display_artifact(mask, requested_hash)
+ else {
+ return Ok(tsjs_not_found_response());
+ };
+ let mut resp = serve_precomputed_tsjs_artifact(artifact, req);
+ strip_tsjs_head_body(&mut resp, req.method());
+ apply_tsjs_success_headers(&mut resp);
+ return Ok(resp);
+ }
+
+ let Some(requested_hash) = req.uri().query().and_then(parse_tsjs_hash_query) else {
+ return Ok(tsjs_not_found_response());
+ };
+
+ if filename == "tsjs-unified.min.js" {
+ let Some(artifact) = integration_registry.tsjs_static_artifact(requested_hash) else {
+ return Ok(tsjs_not_found_response());
+ };
+ let mut resp = serve_precomputed_tsjs_artifact(artifact, req);
+ strip_tsjs_head_body(&mut resp, req.method());
+ apply_tsjs_success_headers(&mut resp);
+ return Ok(resp);
}
if let Some(module_id) = parse_single_module_filename(filename) {
- // Deferred modules and the conditionally injected diagnostics module
- // are served as content-addressed standalone assets. Delivery remains
- // cookie-independent so the static response can stay publicly cached.
- let deferred_ids = integration_registry.js_module_ids_deferred();
- let diagnostics_standalone = module_id
- == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID
- && integration_registry.integration_enabled(module_id);
- if !deferred_ids.contains(&module_id) && !diagnostics_standalone {
- return Ok(not_found_response());
- }
- if let (Some(content), Some(hash)) = (
- trusted_server_js::module_bundle(module_id),
- trusted_server_js::single_module_hash(module_id),
- ) {
- return Ok(serve_tsjs_static(req, content, hash, edge_header));
+ let render_trace_overlay = crate::trace_cookie::render_trace_overlay_active(req);
+ let module_enabled = integration_registry
+ .tsjs_static_transport_selections(render_trace_overlay)
+ .into_iter()
+ .any(|selection| {
+ integration_registry
+ .tsjs_deferred_module_ids(selection)
+ .contains(&module_id)
+ });
+ if module_enabled
+ && trusted_server_js::single_module_hash(module_id).as_deref() == Some(requested_hash)
+ && let Some(content) = trusted_server_js::module_bundle(module_id)
+ {
+ let mut resp = serve_static_with_etag(
+ content,
+ req,
+ "application/javascript; charset=utf-8",
+ edge_header,
+ );
+ strip_tsjs_head_body(&mut resp, req.method());
+ apply_tsjs_success_headers(&mut resp);
+ return Ok(resp);
}
}
- Ok(not_found_response())
+ Ok(tsjs_not_found_response())
}
-fn serve_tsjs_static(
- req: &Request,
- body: &str,
- expected_hash: &str,
- edge_header: EdgeCacheHeader,
-) -> Response {
- let mut response = serve_static_with_etag(
- body,
- req,
- "application/javascript; charset=utf-8",
- edge_header,
- );
- if request_version_hash(req).is_some_and(|hash| hash == expected_hash) {
- CachePolicy::public_immutable(Duration::from_secs(31_536_000))
- .apply_to_headers(response.headers_mut(), edge_header);
+fn strip_tsjs_head_body(response: &mut Response, method: &Method) {
+ if method == Method::HEAD {
+ *response.body_mut() = EdgeBody::empty();
+ }
+}
+
+fn valid_lowercase_sha256(value: &str) -> bool {
+ value.len() == 64
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+fn parse_tsjs_hash_query(query: &str) -> Option<&str> {
+ let hash = query.strip_prefix("v=")?;
+ valid_lowercase_sha256(hash).then_some(hash)
+}
+
+fn parse_first_display_artifact_query(query: &str) -> Option<(u16, &str)> {
+ let (mask, hash) = query.strip_prefix("m=")?.split_once("&v=")?;
+ if mask.len() != 4
+ || !mask
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ || !valid_lowercase_sha256(hash)
+ {
+ return None;
}
+ let mask = u16::from_str_radix(mask, 16).ok()?;
+ (mask & 1 == 1).then_some((mask, hash))
+}
+
+fn apply_tsjs_success_headers(response: &mut Response) {
response
.headers_mut()
.insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on"));
- response
+ response.headers_mut().insert(
+ header::X_CONTENT_TYPE_OPTIONS,
+ HeaderValue::from_static("nosniff"),
+ );
}
-fn request_version_hash(req: &Request) -> Option<&str> {
- req.uri().query()?.split('&').find_map(|pair| {
- let (name, value) = pair.split_once('=')?;
- (name == "v").then_some(value)
- })
+fn serve_precomputed_tsjs_artifact(
+ artifact: &crate::tsjs::TsjsStaticArtifactV1,
+ req: &Request,
+) -> Response {
+ let etag = format!("\"sha256-{}\"", artifact.hash());
+ let not_modified = req
+ .headers()
+ .get(header::IF_NONE_MATCH)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| value == etag);
+ let builder = Response::builder()
+ .header(
+ header::CACHE_CONTROL,
+ "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400",
+ )
+ .header("surrogate-control", "max-age=300")
+ .header(header::ETAG, etag)
+ .header(header::VARY, "Accept-Encoding");
+ if not_modified {
+ return builder
+ .status(StatusCode::NOT_MODIFIED)
+ .body(EdgeBody::empty())
+ .expect("should build 304 TSJS response");
+ }
+
+ builder
+ .status(StatusCode::OK)
+ .header(
+ header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ )
+ .body(EdgeBody::from_bytes(artifact.body().clone()))
+ .expect("should build TSJS response")
}
-/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`.
+/// Extract a catalogued deferred module ID from its only admitted filename.
///
/// Returns `Some(&'static str)` if the filename matches a known JS module ID,
/// `None` otherwise. The caller must additionally verify that the module is
@@ -605,11 +709,15 @@ fn request_version_hash(req: &Request) -> Option<&str> {
fn parse_single_module_filename(filename: &str) -> Option<&'static str> {
let stem = filename
.strip_prefix("tsjs-")
- .and_then(|s| s.strip_suffix(".min.js").or_else(|| s.strip_suffix(".js")))?;
+ .and_then(|s| s.strip_suffix(".min.js"))?;
- trusted_server_js::all_module_ids()
+ trusted_server_js::all_integration_metadata()
.into_iter()
- .find(|&id| id == stem)
+ .find(|metadata| {
+ metadata.id == stem
+ && metadata.phase == Some(trusted_server_js::TsjsModulePhase::Deferred)
+ })
+ .map(|metadata| metadata.id)
}
/// Parameters for processing response streaming.
@@ -627,7 +735,7 @@ struct ProcessResponseParams<'a> {
suppress_datadome_client_side_tag: bool,
gpt_diagnostics:
Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>,
- /// See [`HtmlStreamProcessorParams::shared_template_authorized`].
+ render_trace_overlay: bool,
shared_template_authorized: bool,
/// See [`HtmlStreamProcessorParams::csp_nonce_observed`].
csp_nonce_observed: Option<&'a Arc>,
@@ -657,7 +765,11 @@ impl PublisherBodyProcessor {
ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()),
suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag,
gpt_diagnostics: params.gpt_diagnostics.clone(),
- shared_template_authorized: params.template_cache_key.is_some(),
+ render_trace_overlay: params.render_trace_overlay,
+ assembly_mode: effective_assembly_mode(
+ settings,
+ params.template_cache_key.is_some(),
+ ),
csp_nonce_observed: params.csp_nonce_observed.clone(),
})?)
} else if is_rsc_flight {
@@ -738,7 +850,11 @@ fn process_response_streaming(
ad_bids_state: params.ad_bids_state.clone(),
suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag,
gpt_diagnostics: params.gpt_diagnostics.cloned(),
- shared_template_authorized: params.shared_template_authorized,
+ render_trace_overlay: params.render_trace_overlay,
+ assembly_mode: effective_assembly_mode(
+ params.settings,
+ params.shared_template_authorized,
+ ),
csp_nonce_observed: params.csp_nonce_observed.cloned(),
})?;
StreamingPipeline::new(config, processor)
@@ -965,251 +1081,49 @@ impl Drop for DispatchedAuctionGuard {
}
}
-/// Mutable auction-hold state threaded through the streaming hold pipeline.
-struct AuctionHoldState {
- hold: Option,
- dispatched: DispatchedAuctionGuard,
- telemetry: AuctionTelemetryCarry,
-}
-
-impl AuctionHoldState {
- fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self {
- Self {
- hold: Some(BodyCloseHoldBuffer::new()),
- dispatched,
- telemetry,
- }
- }
-}
-
-/// Abandon the in-flight auction (if still pending) with the given telemetry
-/// reason. No-op once the auction has been collected or already abandoned.
-async fn abandon_hold_auction(
- state: &mut AuctionHoldState,
- services: &RuntimeServices,
- reason: &'static str,
-) {
- if let Some(dispatched) = state.dispatched.take() {
- emit_abandoned_auction(
- services,
- state.telemetry.observation.take(),
- dispatched,
- reason,
- )
- .await;
- // Abandonment with telemetry is a terminal result, so the drop warning
- // is no longer warranted. (A drop *during* the emit above still fires
- // it, since the guard stays armed until here.)
- state.dispatched.disarm();
- }
-}
+/// The single inert marker stored in a reader-neutral C2 template.
+pub const AD_ASSEMBLY_SEAM: &str = "";
-/// Output of a single close-body hold step, split at the auction-collection
-/// barrier.
+/// Transform-owned stand-in placed at the parser-authenticated body seam.
///
-/// `ready` is the prefix the caller must emit *before* collecting the auction,
-/// so a small page whose `` lands in the first source chunk still
-/// streams its document prefix immediately instead of stalling behind the
-/// auction. `close_found` signals that `